# Public Video Gallery ## Overview The Public Video Gallery provides a visitor-friendly interface for browsing and watching shared videos without requiring authentication. Built with category-based organization, reaction systems, and view tracking, it transforms the admin video library into a public-facing media platform similar to YouTube or Vimeo. **Key Features:** - **Public Access** — No login required, SEO-friendly URLs - **Category Organization** — Browse by Entertainment, Education, Sports, News, etc. - **Lock/Unlock System** — Admins control which videos are public via Shared Media page - **Reaction System** — 6 emoji reactions (Like, Love, Laugh, Surprise, Sad, Angry) - **Comment System** — Visitor comments with name/email (moderation pending) - **View Tracking** — Track total views + watch time per video - **Upvote System** — Visitors upvote favorite videos (ranking algorithm) - **Related Videos** — Show 3 similar videos below player - **Responsive Design** — Mobile-friendly grid layout - **Video Player** — HTML5 player with controls, fullscreen, playback speed - **Social Sharing** — Share video URLs on social media **Access Control:** - **Public Routes** — No authentication required - **Admin Control** — Shared Media page (SUPER_ADMIN only) controls which videos are public - **Unlocking Videos** — Removes from public gallery (not deleted, just hidden) **Technology Stack:** - **Frontend:** React + Ant Design + react-player - **Backend:** Fastify media API public routes (no auth) - **Caching:** Redis for public video lists (5 min TTL) - **SEO:** Server-side meta tags, sitemap generation --- ## Architecture ```mermaid flowchart TB subgraph "Public Users" U1[Desktop Browser] U2[Mobile Browser] U3[Social Media Bot] end subgraph "Admin Control" A1[Admin User] A2[SharedMediaPage] end subgraph "Public Routes (No Auth)" P1[GET /api/public/media] P2[GET /api/public/media/:id] P3[POST /api/public/media/:id/view] P4[POST /api/public/media/:id/reaction] P5[POST /api/public/media/:id/comment] end subgraph "Admin Routes (Auth)" A3[PUT /api/media/videos/:id/share] A4[PUT /api/media/videos/:id/unshare] end subgraph "Database" D1[(videos table)] D2[(reactions table)] D3[(comments table)] D4[(view_logs table)] end subgraph "Cache" C1[Redis
Public Videos
5 min TTL] end U1 --> P1 U2 --> P1 U3 --> P1 U1 --> P2 U2 --> P2 U1 --> P3 U1 --> P4 U1 --> P5 A1 --> A2 A2 --> A3 A2 --> A4 P1 --> C1 C1 --> D1 P2 --> D1 P3 --> D4 P4 --> D2 P5 --> D3 A3 --> D1 A4 --> D1 style P1 fill:#2ecc71 style P2 fill:#2ecc71 style C1 fill:#e74c3c style A2 fill:#3498db ``` **Workflow:** 1. **Admin Shares Video** — Admin clicks "Share" button on SharedMediaPage → video marked public 2. **Public Browse** — Visitor navigates to /media → sees grid of public videos 3. **Video Player** — Visitor clicks video card → opens /media/:id → player page 4. **Engagement** — Visitor reacts, comments, or shares video 5. **View Tracking** — Frontend tracks watch time, sends to API on pause/end 6. **Related Videos** — API suggests 3 similar videos (same category/creator) --- ## Database Models ### Videos Table (Public Fields) ```typescript // Only expose public-safe fields interface PublicVideo { id: string; title: string; producer: string; creator: string; durationSeconds: number; quality: string; orientation: string; thumbnailPath: string; publicViewCount: number; publicUpvoteCount: number; createdAt: Date; // Derived fields category: string; // From tags or directoryType isPublic: boolean; // Computed: movedFromPublicAt === null } ``` **Privacy:** Never expose `path`, `filename`, `fileHash`, or internal metadata publicly. --- ### Reactions Table ```sql CREATE TABLE video_reactions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), video_id UUID NOT NULL REFERENCES videos(id), reaction_type TEXT NOT NULL, -- like|love|laugh|surprise|sad|angry session_id TEXT NOT NULL, -- IP hash or session cookie created_at TIMESTAMP DEFAULT NOW(), UNIQUE(video_id, session_id) -- One reaction per user per video ); CREATE INDEX idx_reactions_video ON video_reactions(video_id); CREATE INDEX idx_reactions_session ON video_reactions(session_id); ``` **Reaction Types:** - 👍 `like` — General approval - ❤️ `love` — Strong positive emotion - 😂 `laugh` — Funny/amusing - 😮 `surprise` — Surprising/shocking - 😢 `sad` — Sad/emotional - 😠 `angry` — Frustrating/angering **Session Tracking:** ```typescript // Use IP hash for anonymous users const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex'); // Or use cookie for persistent tracking const sessionId = req.cookies.sessionId || randomUUID(); res.cookie('sessionId', sessionId, { maxAge: 365 * 24 * 60 * 60 * 1000 }); // 1 year ``` --- ### Comments Table ```sql CREATE TABLE video_comments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), video_id UUID NOT NULL REFERENCES videos(id), name TEXT NOT NULL, email TEXT, -- Optional, for moderation notifications comment TEXT NOT NULL, approved BOOLEAN DEFAULT FALSE, -- Moderation flag session_id TEXT, -- For tracking duplicate comments created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_comments_video ON video_comments(video_id); CREATE INDEX idx_comments_approved ON video_comments(approved); ``` **Moderation Workflow:** 1. User submits comment → stored with `approved = false` 2. Admin reviews comment in moderation dashboard 3. Admin clicks "Approve" → `approved = true`, comment visible 4. Admin clicks "Reject" → comment remains hidden or deleted --- ### View Logs Table ```sql CREATE TABLE video_view_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), video_id UUID NOT NULL REFERENCES videos(id), session_id TEXT NOT NULL, watch_time_seconds INTEGER DEFAULT 0, -- Actual watch time (not video duration) completed BOOLEAN DEFAULT FALSE, -- Watched > 90% created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_view_logs_video ON video_view_logs(video_id); CREATE INDEX idx_view_logs_session ON video_view_logs(session_id, video_id); ``` **Watch Time Tracking:** ```typescript // Frontend sends watch time on pause/end let watchTime = 0; const interval = setInterval(() => { if (!player.paused) { watchTime++; } }, 1000); // On pause or end const handlePause = async () => { await axios.post(`/api/public/media/${videoId}/view`, { watchTimeSeconds: watchTime, completed: watchTime >= video.durationSeconds * 0.9, }); }; ``` --- ## API Endpoints (Public) All endpoints are **public** (no authentication required). ### List Public Videos ```http GET /api/public/media ``` **Query Parameters:** | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `page` | number | 1 | Page number | | `limit` | number | 24 | Results per page | | `category` | string | - | Filter by category | | `orientation` | string | - | Filter by orientation (portrait/landscape/square) | | `quality` | string | - | Filter by quality (SD/HD/FHD/UHD) | | `sort` | string | recent | Sort by: recent, popular, trending | **Response:** ```json { "data": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "title": "Amazing Sports Highlight", "producer": "Studio A", "creator": "Director B", "durationSeconds": 125, "quality": "FHD", "orientation": "landscape", "thumbnailPath": "/media/thumbnails/550e8400.jpg", "publicViewCount": 1250, "publicUpvoteCount": 85, "category": "Sports", "createdAt": "2026-02-10T12:00:00Z" } ], "pagination": { "page": 1, "limit": 24, "total": 156, "totalPages": 7 } } ``` **Caching:** ```typescript // Cache public video lists for 5 minutes const cacheKey = `public:videos:${JSON.stringify(query)}`; const cached = await redisClient.get(cacheKey); if (cached) { return reply.send(JSON.parse(cached)); } // Fetch from database const videos = await db.select()...; // Cache for 5 minutes await redisClient.setex(cacheKey, 300, JSON.stringify(videos)); ``` --- ### Get Video Details ```http GET /api/public/media/:id ``` **Response:** ```json { "video": { "id": "550e8400-e29b-41d4-a716-446655440000", "title": "Amazing Sports Highlight", "producer": "Studio A", "creator": "Director B", "durationSeconds": 125, "quality": "FHD", "orientation": "landscape", "width": 1920, "height": 1080, "thumbnailPath": "/media/thumbnails/550e8400.jpg", "publicViewCount": 1251, "publicUpvoteCount": 85, "category": "Sports", "createdAt": "2026-02-10T12:00:00Z", "reactions": { "like": 45, "love": 20, "laugh": 10, "surprise": 5, "sad": 3, "angry": 2 } }, "relatedVideos": [ { "id": "660e8400-e29b-41d4-a716-446655440001", "title": "Another Sports Video", "thumbnailPath": "/media/thumbnails/660e8400.jpg", "durationSeconds": 90 }, { "id": "770e8400-e29b-41d4-a716-446655440002", "title": "Top Plays Compilation", "thumbnailPath": "/media/thumbnails/770e8400.jpg", "durationSeconds": 180 } ], "comments": [ { "id": "880e8400-e29b-41d4-a716-446655440003", "name": "John Doe", "comment": "Amazing video!", "createdAt": "2026-02-12T14:30:00Z" } ] } ``` **Related Videos Algorithm:** ```typescript // Find 3 similar videos const relatedVideos = await db.select() .from(videos) .where( and( eq(videos.isPublic, true), eq(videos.category, video.category), // Same category not(eq(videos.id, video.id)) // Not current video ) ) .orderBy(desc(videos.publicViewCount)) // Most popular first .limit(3); ``` --- ### Track Video View ```http POST /api/public/media/:id/view ``` **Request Body:** ```json { "watchTimeSeconds": 120, "completed": true } ``` **Response:** ```json { "success": true, "newViewCount": 1252 } ``` **Process:** 1. Get session ID (IP hash or cookie) 2. Check if already viewed in last 24 hours (prevent duplicate counting) 3. Create view log record 4. Increment video `publicViewCount` 5. Return new view count --- ### Add/Update Reaction ```http POST /api/public/media/:id/reaction ``` **Request Body:** ```json { "reactionType": "like" } ``` **Response:** ```json { "success": true, "reactions": { "like": 46, "love": 20, "laugh": 10, "surprise": 5, "sad": 3, "angry": 2 } } ``` **Process:** 1. Get session ID 2. Check if user already reacted 3. If same reaction, remove it (toggle off) 4. If different reaction, update it 5. If no reaction, insert new one 6. Return updated reaction counts --- ### Submit Comment ```http POST /api/public/media/:id/comment ``` **Request Body:** ```json { "name": "John Doe", "email": "john@example.com", "comment": "This video is amazing! Thanks for sharing." } ``` **Response:** ```json { "success": true, "message": "Comment submitted for moderation" } ``` **Validation:** - Name: 1-100 characters - Email: Optional, valid email format - Comment: 1-1000 characters, no HTML allowed **Anti-Spam:** - Rate limit: 5 comments per hour per session - Duplicate detection: reject if same comment in last 24 hours --- ## Admin Workflow ### Sharing Videos (Making Public) 1. Navigate to **Media → Shared Media** page 2. Table shows all videos with "Public" toggle switch 3. **To share video:** - Click toggle switch to ON (blue) - Video immediately appears in public gallery - Modal prompts for category selection (optional) 4. **To unshare video:** - Click toggle switch to OFF (grey) - Video removed from public gallery - `movedFromPublicAt` timestamp set (preserves history) **Shared Media Page Features:** - **Category Management** — Assign videos to categories (Entertainment, Education, Sports, etc.) - **Bulk Actions** — Select multiple videos, share/unshare all at once - **Preview** — Click "Preview" button to see public view - **Stats** — View count, upvote count, reaction breakdown - **Lock Indicator** — Icon shows which videos are currently public --- ### Setting Categories **Option 1: Tag-Based Categories** Use video tags to auto-assign categories: ```typescript // If video has "sports" tag → Sports category // If video has "education" or "tutorial" tag → Education category const detectCategory = (tags: string[]): string => { if (tags.some(t => ['sports', 'game', 'play'].includes(t.toLowerCase()))) { return 'Sports'; } if (tags.some(t => ['education', 'tutorial', 'learn'].includes(t.toLowerCase()))) { return 'Education'; } if (tags.some(t => ['entertainment', 'comedy', 'music'].includes(t.toLowerCase()))) { return 'Entertainment'; } return 'Other'; }; ``` **Option 2: Manual Assignment** 1. Select video in Shared Media page 2. Click "Edit Category" button 3. Modal opens with category dropdown: - Entertainment - Education - Sports - News - Music - Gaming - Science & Tech - Travel - Other 4. Click "Save" 5. Category updated immediately --- ### Viewing Statistics **Per-Video Stats:** 1. Click video row in Shared Media page 2. Stats drawer slides in from right showing: - **Total Views** — All-time view count - **Average Watch Time** — Mean watch time (seconds) - **Completion Rate** — % of viewers who watched > 90% - **Upvotes** — Total upvote count - **Reactions Breakdown** — Chart showing reaction distribution - **Top Referrers** — Where views came from (direct, social, etc.) - **View Trend** — Line chart of views over last 30 days **Gallery-Wide Stats:** Dashboard widget showing: - Total public videos - Total views across all videos - Most popular video (by views) - Trending video (highest growth rate) - Total reactions - Total comments (pending + approved) --- ### Moderating Comments 1. Navigate to **Media → Comments** page (or notification badge in sidebar) 2. Table shows all comments with filters: - **Pending** — Awaiting moderation - **Approved** — Visible on public gallery - **Rejected** — Hidden from public 3. **To approve comment:** - Click "Approve" button - Comment appears on video page immediately 4. **To reject comment:** - Click "Reject" button - Comment hidden (or deleted) - Optional: Send email to commenter explaining why **Bulk Moderation:** - Select multiple comments via checkboxes - Click "Approve All" or "Reject All" - Batch updates applied instantly --- ## Public User Workflow ### Browsing Gallery 1. Navigate to **https://cmlite.org/media** 2. Hero section shows featured video (most popular or admin-selected) 3. Category tabs below hero: - All - Entertainment - Education - Sports - News - Music - Gaming - Science & Tech 4. Grid of video cards (4 per row on desktop, 2 on tablet, 1 on mobile) 5. Each card shows: - Thumbnail image - Title - Producer/creator - Duration badge - View count - Quality badge (HD, FHD, UHD) **Infinite Scroll:** - As user scrolls to bottom, next page loads automatically - Loading spinner shows while fetching - No "Load More" button needed --- ### Watching Video 1. Click video card → navigates to **https://cmlite.org/media/:id** 2. Video player page layout: - **Video Player** — Full-width HTML5 player with controls - **Video Title & Metadata** — Title, producer, creator, view count - **Reaction Bar** — 6 emoji buttons with counts - **Description** — Auto-generated or admin-provided - **Comments Section** — Approved comments + submit form - **Related Videos** — 3 similar videos in sidebar 3. User clicks play → video starts, watch time tracked 4. User clicks reaction → emoji highlighted, count increments 5. User scrolls to comments → reads existing, submits new **Video Player Features:** - Play/pause button - Volume slider - Playback speed (0.5x, 1x, 1.25x, 1.5x, 2x) - Fullscreen button - Current time / total duration - Scrub bar (seek to any position) - Auto-play next related video (optional) --- ### Reacting to Video 1. Click reaction emoji button (e.g., 👍 Like) 2. Button highlights in color 3. Count increments by 1 4. **Toggle behavior:** - Click again → removes reaction, count decrements - Click different emoji → switches reaction 5. Session tracked via cookie (reactions persist across page refreshes) **Reaction Colors:** - Like 👍 — Blue - Love ❤️ — Red - Laugh 😂 — Yellow - Surprise 😮 — Purple - Sad 😢 — Grey - Angry 😠 — Orange --- ### Commenting 1. Scroll to comments section below video 2. Fill out form: - **Name** — Required, displayed publicly - **Email** — Optional, for moderation notifications - **Comment** — Required, 1-1000 characters 3. Click "Submit Comment" 4. Success message: "Comment submitted for moderation" 5. Comment appears in list with "Pending approval" badge 6. After admin approval, comment visible to all **Comment Formatting:** - Plain text only (no HTML) - URLs auto-linked - Line breaks preserved - Profanity filter applied (optional) --- ## Code Examples ### Backend: List Public Videos ```typescript // api/src/modules/media/routes/public.routes.ts import { FastifyInstance } from 'fastify'; import { eq, and, isNull, desc } from 'drizzle-orm'; import { videos } from '@/modules/media/db/schema'; import { redisClient } from '@/config/redis'; export default async function (app: FastifyInstance) { app.get('/api/public/media', async (req, reply) => { const { page = 1, limit = 24, category, orientation, quality, sort = 'recent', } = req.query as any; // Check cache const cacheKey = `public:videos:${JSON.stringify(req.query)}`; const cached = await redisClient.get(cacheKey); if (cached) { return reply.send(JSON.parse(cached)); } // Build filters const filters = [ isNull(videos.movedFromPublicAt), // Only public videos eq(videos.isValid, true), ]; if (category) { filters.push(eq(videos.category, category)); } if (orientation) { filters.push(eq(videos.orientation, orientation)); } if (quality) { filters.push(eq(videos.quality, quality)); } // Build order by let orderBy; if (sort === 'popular') { orderBy = desc(videos.publicViewCount); } else if (sort === 'trending') { // Trending = highest view count in last 7 days // (requires separate view_logs aggregation query) orderBy = desc(videos.publicViewCount); } else { orderBy = desc(videos.createdAt); } // Fetch videos const results = await db .select({ id: videos.id, title: videos.title, producer: videos.producer, creator: videos.creator, durationSeconds: videos.durationSeconds, quality: videos.quality, orientation: videos.orientation, thumbnailPath: videos.thumbnailPath, publicViewCount: videos.publicViewCount, publicUpvoteCount: videos.publicUpvoteCount, category: videos.category, createdAt: videos.createdAt, }) .from(videos) .where(and(...filters)) .orderBy(orderBy) .limit(Number(limit)) .offset((Number(page) - 1) * Number(limit)); // Count total const [{ count }] = await db .select({ count: sql`count(*)` }) .from(videos) .where(and(...filters)); const response = { data: results, pagination: { page: Number(page), limit: Number(limit), total: Number(count), totalPages: Math.ceil(Number(count) / Number(limit)), }, }; // Cache for 5 minutes await redisClient.setex(cacheKey, 300, JSON.stringify(response)); reply.send(response); }); } ``` --- ### Backend: Track View ```typescript // api/src/modules/media/routes/public.routes.ts import { videoViewLogs, videos } from '@/modules/media/db/schema'; import crypto from 'crypto'; app.post('/api/public/media/:id/view', async (req, reply) => { const { id } = req.params as { id: string }; const { watchTimeSeconds, completed } = req.body as any; // Get session ID from IP hash const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex'); // Check if already viewed in last 24 hours const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); const existingView = await db .select() .from(videoViewLogs) .where( and( eq(videoViewLogs.videoId, id), eq(videoViewLogs.sessionId, sessionId), gte(videoViewLogs.createdAt, yesterday) ) ) .limit(1); if (existingView.length > 0) { // Update watch time if longer than previous if (watchTimeSeconds > existingView[0].watchTimeSeconds) { await db .update(videoViewLogs) .set({ watchTimeSeconds, completed: completed || existingView[0].completed, }) .where(eq(videoViewLogs.id, existingView[0].id)); } return reply.send({ success: true, newViewCount: null }); } // Create new view log await db.insert(videoViewLogs).values({ videoId: id, sessionId, watchTimeSeconds, completed, }); // Increment view count const [updated] = await db .update(videos) .set({ publicViewCount: sql`${videos.publicViewCount} + 1`, }) .where(eq(videos.id, id)) .returning({ newViewCount: videos.publicViewCount }); reply.send({ success: true, newViewCount: updated.newViewCount }); }); ``` --- ### Backend: Add Reaction ```typescript // api/src/modules/media/routes/public.routes.ts import { videoReactions } from '@/modules/media/db/schema'; app.post('/api/public/media/:id/reaction', async (req, reply) => { const { id } = req.params as { id: string }; const { reactionType } = req.body as { reactionType: string }; const validReactions = ['like', 'love', 'laugh', 'surprise', 'sad', 'angry']; if (!validReactions.includes(reactionType)) { return reply.code(400).send({ error: 'Invalid reaction type' }); } const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex'); // Check existing reaction const [existing] = await db .select() .from(videoReactions) .where( and( eq(videoReactions.videoId, id), eq(videoReactions.sessionId, sessionId) ) ) .limit(1); if (existing) { if (existing.reactionType === reactionType) { // Toggle off (remove reaction) await db .delete(videoReactions) .where(eq(videoReactions.id, existing.id)); } else { // Update to new reaction await db .update(videoReactions) .set({ reactionType }) .where(eq(videoReactions.id, existing.id)); } } else { // Insert new reaction await db.insert(videoReactions).values({ videoId: id, sessionId, reactionType, }); } // Get updated reaction counts const reactions = await db .select({ reactionType: videoReactions.reactionType, count: sql`count(*)`, }) .from(videoReactions) .where(eq(videoReactions.videoId, id)) .groupBy(videoReactions.reactionType); const reactionCounts = validReactions.reduce((acc, type) => { acc[type] = reactions.find((r) => r.reactionType === type)?.count || 0; return acc; }, {} as Record); reply.send({ success: true, reactions: reactionCounts }); }); ``` --- ### Frontend: Video Gallery Page ```typescript // admin/src/pages/public/MediaGalleryPage.tsx import { Row, Col, Card, Tag, Tabs, Empty } from 'antd'; import { PlayCircleOutlined, EyeOutlined } from '@ant-design/icons'; import { useEffect, useState } from 'react'; import axios from 'axios'; import InfiniteScroll from 'react-infinite-scroll-component'; export default function MediaGalleryPage() { const [videos, setVideos] = useState([]); const [category, setCategory] = useState(''); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const fetchVideos = async () => { try { const { data } = await axios.get('http://api.cmlite.org/api/public/media', { params: { page, limit: 24, category: category || undefined, }, }); setVideos((prev) => [...prev, ...data.data]); setHasMore(page < data.pagination.totalPages); } catch (error) { console.error('Failed to fetch videos:', error); } }; useEffect(() => { setVideos([]); setPage(1); setHasMore(true); }, [category]); useEffect(() => { fetchVideos(); }, [page, category]); const categories = [ { key: '', label: 'All' }, { key: 'Entertainment', label: 'Entertainment' }, { key: 'Education', label: 'Education' }, { key: 'Sports', label: 'Sports' }, { key: 'News', label: 'News' }, { key: 'Music', label: 'Music' }, { key: 'Gaming', label: 'Gaming' }, { key: 'Science & Tech', label: 'Science & Tech' }, ]; return (

Video Gallery

({ key: cat.key, label: cat.label, }))} style={{ marginBottom: 24 }} /> setPage((p) => p + 1)} hasMore={hasMore} loader={
Loading...
} endMessage={ } > {videos.map((video) => ( {video.title}
{Math.floor(video.durationSeconds / 60)}: {(video.durationSeconds % 60).toString().padStart(2, '0')}
} onClick={() => (window.location.href = `/media/${video.id}`)} > {video.title} } description={
{video.producer}
{video.publicViewCount.toLocaleString()} {video.quality}
} /> ))} ); } ``` --- ### Frontend: Video Player Page ```typescript // admin/src/pages/public/MediaViewerPage.tsx import { useParams } from 'react-router-dom'; import { useEffect, useState } from 'react'; import axios from 'axios'; import ReactPlayer from 'react-player'; import { Button, Row, Col, Card, Divider, Form, Input, message } from 'antd'; export default function MediaViewerPage() { const { id } = useParams<{ id: string }>(); const [video, setVideo] = useState(null); const [watchTime, setWatchTime] = useState(0); const [userReaction, setUserReaction] = useState(null); useEffect(() => { fetchVideo(); }, [id]); const fetchVideo = async () => { const { data } = await axios.get(`http://api.cmlite.org/api/public/media/${id}`); setVideo(data.video); }; const trackView = async () => { await axios.post(`http://api.cmlite.org/api/public/media/${id}/view`, { watchTimeSeconds: watchTime, completed: watchTime >= video.durationSeconds * 0.9, }); }; const handleReaction = async (reactionType: string) => { const { data } = await axios.post(`http://api.cmlite.org/api/public/media/${id}/reaction`, { reactionType, }); setUserReaction(userReaction === reactionType ? null : reactionType); setVideo({ ...video, reactions: data.reactions }); }; const handleSubmitComment = async (values: any) => { await axios.post(`http://api.cmlite.org/api/public/media/${id}/comment`, values); message.success('Comment submitted for moderation'); }; if (!video) return
Loading...
; const reactions = [ { type: 'like', emoji: '👍', label: 'Like' }, { type: 'love', emoji: '❤️', label: 'Love' }, { type: 'laugh', emoji: '😂', label: 'Laugh' }, { type: 'surprise', emoji: '😮', label: 'Surprise' }, { type: 'sad', emoji: '😢', label: 'Sad' }, { type: 'angry', emoji: '😠', label: 'Angry' }, ]; return (
setWatchTime(Math.floor(state.playedSeconds))} onPause={trackView} onEnded={trackView} />

{video.title}

{video.producer} • {video.publicViewCount.toLocaleString()} views
{reactions.map((r) => ( ))}

Comments

{video.comments.map((comment: any) => (
{new Date(comment.createdAt).toLocaleDateString()}
))}

Related Videos

{video.relatedVideos.map((related: any) => ( } onClick={() => (window.location.href = `/media/${related.id}`)} style={{ marginBottom: 16 }} > ))}
); } ``` --- ## Troubleshooting ### Problem: Videos Not Appearing in Gallery **Symptoms:** - SharedMediaPage shows videos marked as public - Public gallery shows "No videos found" - API returns empty array **Solutions:** 1. **Check `movedFromPublicAt` field:** ```sql SELECT id, title, moved_from_public_at FROM videos WHERE moved_from_public_at IS NULL; -- Should show public videos -- If all have timestamps, videos were unlocked -- Fix: Set to NULL for videos that should be public UPDATE videos SET moved_from_public_at = NULL WHERE id = 'VIDEO_ID'; ``` 2. **Verify `isValid = true`:** ```sql SELECT id, title, is_valid FROM videos WHERE is_valid = false; -- Invalid videos hidden from public -- Fix: Validate videos to mark as valid ``` 3. **Check Redis cache:** ```bash # Clear public video cache docker compose exec redis redis-cli > KEYS public:videos:* > DEL public:videos:* # Refresh gallery page ``` 4. **Test API directly:** ```bash curl http://localhost:4100/api/public/media # Should return JSON with videos array ``` --- ### Problem: Reactions Not Saving **Symptoms:** - Click reaction button, count doesn't increment - Refresh page, reaction disappears - No errors in console **Solutions:** 1. **Check session ID generation:** ```typescript // Backend should use consistent session ID const sessionId = crypto.createHash('sha256').update(req.ip).digest('hex'); // Or use cookie for persistence const sessionId = req.cookies.sessionId || randomUUID(); res.cookie('sessionId', sessionId, { maxAge: 365 * 24 * 60 * 60 * 1000 }); ``` 2. **Verify database insert:** ```sql SELECT * FROM video_reactions WHERE video_id = 'VIDEO_ID'; -- Should show reaction records -- If empty, insert is failing -- Check unique constraint: (video_id, session_id) ``` 3. **Test reaction endpoint:** ```bash curl -X POST http://localhost:4100/api/public/media/VIDEO_ID/reaction \ -H "Content-Type: application/json" \ -d '{"reactionType": "like"}' # Should return updated reaction counts ``` --- ### Problem: Comments Not Showing After Approval **Symptoms:** - Admin approves comment - Comment still doesn't appear on video page - Database shows `approved = true` **Solutions:** 1. **Check query filter:** ```typescript // Backend should filter for approved comments const comments = await db .select() .from(videoComments) .where( and( eq(videoComments.videoId, videoId), eq(videoComments.approved, true) // MUST include this ) ) .orderBy(desc(videoComments.createdAt)); ``` 2. **Clear cache:** ```bash # Video details may be cached docker compose exec redis redis-cli DEL "public:video:VIDEO_ID" ``` 3. **Verify approval:** ```sql SELECT id, comment, approved FROM video_comments WHERE video_id = 'VIDEO_ID'; -- Should show approved = true ``` --- ## Performance Considerations ### Redis Caching Strategy **Cache Keys:** - `public:videos:{query}` — List of videos (5 min TTL) - `public:video:{id}` — Video details (10 min TTL) - `public:stats` — Gallery-wide stats (15 min TTL) **Cache Invalidation:** ```typescript // When admin shares/unshares video await redisClient.del(`public:videos:*`); // Clear all list caches await redisClient.del(`public:video:${videoId}`); // Clear detail cache // When comment approved await redisClient.del(`public:video:${videoId}`); // Refresh comments ``` --- ### Database Indexes ```sql -- Public video queries CREATE INDEX idx_videos_public ON videos(moved_from_public_at) WHERE moved_from_public_at IS NULL; CREATE INDEX idx_videos_category ON videos(category, created_at DESC); CREATE INDEX idx_videos_popular ON videos(public_view_count DESC); -- Reactions CREATE INDEX idx_reactions_video ON video_reactions(video_id); CREATE INDEX idx_reactions_session ON video_reactions(session_id); -- Comments CREATE INDEX idx_comments_video_approved ON video_comments(video_id, approved); -- View logs CREATE INDEX idx_view_logs_video ON video_view_logs(video_id); CREATE INDEX idx_view_logs_recent ON video_view_logs(created_at DESC); ``` --- ### SEO Optimization **Server-Side Rendering (Future):** ```typescript // Next.js or similar for SSR export async function getServerSideProps({ params }: { params: { id: string } }) { const video = await fetchVideo(params.id); return { props: { video, meta: { title: video.title, description: `Watch ${video.title} by ${video.producer}`, image: video.thumbnailPath, url: `https://cmlite.org/media/${video.id}`, }, }, }; } ``` **Meta Tags:** ```html Amazing Sports Highlight | CMLite Gallery ``` **Sitemap Generation:** ```xml https://cmlite.org/media daily 1.0 https://cmlite.org/media/550e8400-e29b-41d4-a716-446655440000 2026-02-10 weekly 0.8 ``` --- ## Security Considerations ### Rate Limiting ```typescript // Public endpoints more restrictive than admin import rateLimit from '@fastify/rate-limit'; app.register(rateLimit, { max: 100, // 100 requests timeWindow: '1 minute', allowList: [], // No whitelist for public }); ``` **Per-Endpoint Limits:** - List videos: 100/min - Video details: 100/min - Track view: 10/min (prevent view count manipulation) - Add reaction: 20/min - Submit comment: 5/hour (anti-spam) --- ### Content Moderation **Comment Filtering:** ```typescript import Filter from 'bad-words'; const filter = new Filter(); const sanitizeComment = (comment: string): string => { // Remove HTML tags const cleaned = comment.replace(/<[^>]*>/g, ''); // Filter profanity return filter.clean(cleaned); }; ``` **Spam Detection:** ```typescript // Reject duplicate comments const existingComment = await db.select() .from(videoComments) .where( and( eq(videoComments.sessionId, sessionId), eq(videoComments.comment, comment), gte(videoComments.createdAt, new Date(Date.now() - 24 * 60 * 60 * 1000)) ) ) .limit(1); if (existingComment.length > 0) { return reply.code(429).send({ error: 'Duplicate comment detected' }); } ``` --- ### Privacy Protection **Never Expose:** - Internal file paths (`/media/local/library/...`) - Original filenames (use video ID for playback URL) - Admin user information - Email addresses from comments (unless user explicitly made public) **Session Tracking:** ```typescript // Use IP hash (not raw IP) for session ID const sessionId = crypto.createHash('sha256').update(req.ip + 'SECRET_SALT').digest('hex'); // Store minimal data in session // NO: { userId: 123, name: 'John', email: 'john@example.com' } // YES: { sessionId: 'abc123' } ``` --- ## Related Documentation ### Backend Documentation - **Public Routes:** `backend/modules/media/public.md` — Public API endpoints - **Reactions Service:** `backend/modules/media/reactions.md` — Reaction system implementation - **Comments Service:** `backend/modules/media/comments.md` — Comment moderation system ### Frontend Documentation - **Media Gallery Page:** `frontend/pages/public/media-gallery.md` — Gallery UI implementation - **Video Player Page:** `frontend/pages/public/media-viewer.md` — Player component ### Feature Documentation - **Video Library:** `features/media/video-library.md` — Admin video management - **Shared Media:** `features/media/shared-media.md` — Sharing controls (admin) --- ## Next Steps After mastering the public gallery: 1. **Analytics Dashboard** — Build admin dashboard showing view trends, popular videos, engagement metrics 2. **Playlist System** — Allow users to create and share playlists 3. **Video Embedding** — Generate embed codes for external websites 4. **Advanced Search** — Full-text search across titles, producers, creators, tags **Hands-On Practice:** ```bash # 1. Share video via API curl -X PUT http://localhost:4100/api/media/videos/VIDEO_ID/share \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"category": "Sports"}' # 2. Browse public gallery curl http://localhost:4100/api/public/media?category=Sports # 3. Track view curl -X POST http://localhost:4100/api/public/media/VIDEO_ID/view \ -H "Content-Type: application/json" \ -d '{"watchTimeSeconds": 120, "completed": true}' # 4. Add reaction curl -X POST http://localhost:4100/api/public/media/VIDEO_ID/reaction \ -H "Content-Type: application/json" \ -d '{"reactionType": "like"}' ``` --- **Last Updated:** 2026-02-13 **Version:** V2.0 **Maintainer:** Changemaker Lite Team