42 KiB
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
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<br/>Public Videos<br/>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:
- Admin Shares Video — Admin clicks "Share" button on SharedMediaPage → video marked public
- Public Browse — Visitor navigates to /media → sees grid of public videos
- Video Player — Visitor clicks video card → opens /media/:id → player page
- Engagement — Visitor reacts, comments, or shares video
- View Tracking — Frontend tracks watch time, sends to API on pause/end
- Related Videos — API suggests 3 similar videos (same category/creator)
Database Models
Videos Table (Public Fields)
// 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
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:
// 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
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:
- User submits comment → stored with
approved = false - Admin reviews comment in moderation dashboard
- Admin clicks "Approve" →
approved = true, comment visible - Admin clicks "Reject" → comment remains hidden or deleted
View Logs Table
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:
// 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
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:
{
"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:
// 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
GET /api/public/media/:id
Response:
{
"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:
// 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
POST /api/public/media/:id/view
Request Body:
{
"watchTimeSeconds": 120,
"completed": true
}
Response:
{
"success": true,
"newViewCount": 1252
}
Process:
- Get session ID (IP hash or cookie)
- Check if already viewed in last 24 hours (prevent duplicate counting)
- Create view log record
- Increment video
publicViewCount - Return new view count
Add/Update Reaction
POST /api/public/media/:id/reaction
Request Body:
{
"reactionType": "like"
}
Response:
{
"success": true,
"reactions": {
"like": 46,
"love": 20,
"laugh": 10,
"surprise": 5,
"sad": 3,
"angry": 2
}
}
Process:
- Get session ID
- Check if user already reacted
- If same reaction, remove it (toggle off)
- If different reaction, update it
- If no reaction, insert new one
- Return updated reaction counts
Submit Comment
POST /api/public/media/:id/comment
Request Body:
{
"name": "John Doe",
"email": "john@example.com",
"comment": "This video is amazing! Thanks for sharing."
}
Response:
{
"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)
- Navigate to Media → Shared Media page
- Table shows all videos with "Public" toggle switch
- To share video:
- Click toggle switch to ON (blue)
- Video immediately appears in public gallery
- Modal prompts for category selection (optional)
- To unshare video:
- Click toggle switch to OFF (grey)
- Video removed from public gallery
movedFromPublicAttimestamp 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:
// 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
- Select video in Shared Media page
- Click "Edit Category" button
- Modal opens with category dropdown:
- Entertainment
- Education
- Sports
- News
- Music
- Gaming
- Science & Tech
- Travel
- Other
- Click "Save"
- Category updated immediately
Viewing Statistics
Per-Video Stats:
- Click video row in Shared Media page
- 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
- Navigate to Media → Comments page (or notification badge in sidebar)
- Table shows all comments with filters:
- Pending — Awaiting moderation
- Approved — Visible on public gallery
- Rejected — Hidden from public
- To approve comment:
- Click "Approve" button
- Comment appears on video page immediately
- 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
- Navigate to https://cmlite.org/media
- Hero section shows featured video (most popular or admin-selected)
- Category tabs below hero:
- All
- Entertainment
- Education
- Sports
- News
- Music
- Gaming
- Science & Tech
- Grid of video cards (4 per row on desktop, 2 on tablet, 1 on mobile)
- 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
- Click video card → navigates to https://cmlite.org/media/:id
- 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
- User clicks play → video starts, watch time tracked
- User clicks reaction → emoji highlighted, count increments
- 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
- Click reaction emoji button (e.g., 👍 Like)
- Button highlights in color
- Count increments by 1
- Toggle behavior:
- Click again → removes reaction, count decrements
- Click different emoji → switches reaction
- Session tracked via cookie (reactions persist across page refreshes)
Reaction Colors:
- Like 👍 — Blue
- Love ❤️ — Red
- Laugh 😂 — Yellow
- Surprise 😮 — Purple
- Sad 😢 — Grey
- Angry 😠 — Orange
Commenting
- Scroll to comments section below video
- Fill out form:
- Name — Required, displayed publicly
- Email — Optional, for moderation notifications
- Comment — Required, 1-1000 characters
- Click "Submit Comment"
- Success message: "Comment submitted for moderation"
- Comment appears in list with "Pending approval" badge
- 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
// 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<number>`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
// 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
// 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<number>`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<string, number>);
reply.send({ success: true, reactions: reactionCounts });
});
Frontend: Video Gallery Page
// 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<any[]>([]);
const [category, setCategory] = useState<string>('');
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 (
<div style={{ padding: 24 }}>
<h1 style={{ fontSize: 32, marginBottom: 24 }}>Video Gallery</h1>
<Tabs
activeKey={category}
onChange={setCategory}
items={categories.map((cat) => ({
key: cat.key,
label: cat.label,
}))}
style={{ marginBottom: 24 }}
/>
<InfiniteScroll
dataLength={videos.length}
next={() => setPage((p) => p + 1)}
hasMore={hasMore}
loader={<div style={{ textAlign: 'center', padding: 24 }}>Loading...</div>}
endMessage={
<Empty description="No more videos" style={{ marginTop: 48 }} />
}
>
<Row gutter={[16, 16]}>
{videos.map((video) => (
<Col key={video.id} xs={24} sm={12} md={8} lg={6}>
<Card
hoverable
cover={
<div
style={{
position: 'relative',
paddingTop: '56.25%',
background: '#000',
}}
>
<img
src={video.thumbnailPath || '/placeholder.jpg'}
alt={video.title}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<div
style={{
position: 'absolute',
top: 8,
right: 8,
background: 'rgba(0,0,0,0.7)',
color: '#fff',
padding: '4px 8px',
borderRadius: 4,
fontSize: 12,
}}
>
{Math.floor(video.durationSeconds / 60)}:
{(video.durationSeconds % 60).toString().padStart(2, '0')}
</div>
<PlayCircleOutlined
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: 48,
color: '#fff',
opacity: 0.8,
}}
/>
</div>
}
onClick={() => (window.location.href = `/media/${video.id}`)}
>
<Card.Meta
title={
<div style={{ fontSize: 14, height: 40, overflow: 'hidden' }}>
{video.title}
</div>
}
description={
<div>
<div style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>
{video.producer}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 12 }}>
<EyeOutlined /> {video.publicViewCount.toLocaleString()}
</span>
<Tag color={video.quality === 'UHD' ? 'purple' : 'blue'}>
{video.quality}
</Tag>
</div>
</div>
}
/>
</Card>
</Col>
))}
</Row>
</InfiniteScroll>
</div>
);
}
Frontend: Video Player Page
// 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<any>(null);
const [watchTime, setWatchTime] = useState(0);
const [userReaction, setUserReaction] = useState<string | null>(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 <div>Loading...</div>;
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 (
<div style={{ maxWidth: 1200, margin: '0 auto', padding: 24 }}>
<Row gutter={24}>
<Col span={16}>
<ReactPlayer
url={`/media/videos/${video.id}.mp4`}
controls
width="100%"
height="auto"
onProgress={(state) => setWatchTime(Math.floor(state.playedSeconds))}
onPause={trackView}
onEnded={trackView}
/>
<h1 style={{ marginTop: 16 }}>{video.title}</h1>
<div style={{ color: '#888', marginBottom: 16 }}>
{video.producer} • {video.publicViewCount.toLocaleString()} views
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
{reactions.map((r) => (
<Button
key={r.type}
type={userReaction === r.type ? 'primary' : 'default'}
onClick={() => handleReaction(r.type)}
>
<span style={{ fontSize: 20, marginRight: 4 }}>{r.emoji}</span>
{video.reactions[r.type] || 0}
</Button>
))}
</div>
<Divider />
<h3>Comments</h3>
{video.comments.map((comment: any) => (
<Card key={comment.id} style={{ marginBottom: 16 }}>
<Card.Meta
title={comment.name}
description={comment.comment}
/>
<div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>
{new Date(comment.createdAt).toLocaleDateString()}
</div>
</Card>
))}
<Form onFinish={handleSubmitComment} layout="vertical">
<Form.Item label="Name" name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="Email" name="email" rules={[{ type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item label="Comment" name="comment" rules={[{ required: true }]}>
<Input.TextArea rows={4} />
</Form.Item>
<Button type="primary" htmlType="submit">
Submit Comment
</Button>
</Form>
</Col>
<Col span={8}>
<h3>Related Videos</h3>
{video.relatedVideos.map((related: any) => (
<Card
key={related.id}
hoverable
cover={<img src={related.thumbnailPath} alt={related.title} />}
onClick={() => (window.location.href = `/media/${related.id}`)}
style={{ marginBottom: 16 }}
>
<Card.Meta title={related.title} />
</Card>
))}
</Col>
</Row>
</div>
);
}
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:
- Check
movedFromPublicAtfield:
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';
- Verify
isValid = true:
SELECT id, title, is_valid FROM videos WHERE is_valid = false;
-- Invalid videos hidden from public
-- Fix: Validate videos to mark as valid
- Check Redis cache:
# Clear public video cache
docker compose exec redis redis-cli
> KEYS public:videos:*
> DEL public:videos:*
# Refresh gallery page
- Test API directly:
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:
- Check session ID generation:
// 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 });
- Verify database insert:
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)
- Test reaction endpoint:
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:
- Check query filter:
// 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));
- Clear cache:
# Video details may be cached
docker compose exec redis redis-cli DEL "public:video:VIDEO_ID"
- Verify approval:
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:
// 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
-- 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):
// 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:
<head>
<title>Amazing Sports Highlight | CMLite Gallery</title>
<meta name="description" content="Watch Amazing Sports Highlight by Studio A. 1,250 views.">
<meta property="og:title" content="Amazing Sports Highlight">
<meta property="og:description" content="Watch Amazing Sports Highlight by Studio A">
<meta property="og:image" content="https://cmlite.org/media/thumbnails/550e8400.jpg">
<meta property="og:url" content="https://cmlite.org/media/550e8400">
<meta property="og:type" content="video.other">
<meta name="twitter:card" content="player">
<meta name="twitter:title" content="Amazing Sports Highlight">
<meta name="twitter:image" content="https://cmlite.org/media/thumbnails/550e8400.jpg">
</head>
Sitemap Generation:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://cmlite.org/media</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://cmlite.org/media/550e8400-e29b-41d4-a716-446655440000</loc>
<lastmod>2026-02-10</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<!-- ... more video URLs -->
</urlset>
Security Considerations
Rate Limiting
// 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:
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:
// 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:
// 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:
- Analytics Dashboard — Build admin dashboard showing view trends, popular videos, engagement metrics
- Playlist System — Allow users to create and share playlists
- Video Embedding — Generate embed codes for external websites
- Advanced Search — Full-text search across titles, producers, creators, tags
Hands-On Practice:
# 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