1668 lines
42 KiB
Markdown
1668 lines
42 KiB
Markdown
# 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<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:**
|
|
|
|
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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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<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:**
|
|
|
|
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
|
|
<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
|
|
<?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
|
|
|
|
```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
|