22 KiB
Media Admin Features - Complete Guide
Overview
The Video Admin Features system provides comprehensive content management capabilities for the Changemaker Lite video library. Implemented in February 2026, this system transforms the basic CRUD interface into a professional-grade video management platform with quick actions, scheduled publishing, and detailed analytics.
Table of Contents
Quick Action Buttons
Overview
Quick action buttons appear on video cards when hovering, providing instant access to common operations without navigating away from the library view.
Features
Primary Actions:
- Edit (E) - Modify video metadata, title, producer, creator
- Preview (P) - Watch video with full analytics tracking
- Analytics (A) - View quick statistics modal
- Schedule (S) - Set publish/unpublish times
Secondary Actions (Overflow Menu):
- Duplicate - Clone video with new title
- Generate Preview Link - Create expiring share link (24h)
- Download - Download original video file (coming soon)
- Generate Thumbnail - Auto-generate thumbnail from video (coming soon)
- Reset Analytics - Clear all view data and statistics
- Delete - Permanently remove video
Keyboard Shortcuts
Quick actions support keyboard shortcuts when the library page is focused:
| Key | Action |
|---|---|
E |
Edit video |
P |
Preview video |
A |
Show analytics |
S |
Schedule publishing |
Note: Shortcuts are disabled when typing in input fields or text areas.
Preview Links
Preview links allow sharing videos with external parties without requiring authentication.
Characteristics:
- JWT-based token authentication
- 24-hour expiration (configurable via
VIDEO_PREVIEW_LINK_EXPIRY_HOURS) - Automatically copied to clipboard
- Single-use tracking (each open creates new view)
Generating a Preview Link:
- Hover over video card
- Click "More Actions" (three dots)
- Select "Generate Preview Link"
- Link is automatically copied to clipboard
- Modal displays link and expiry time
Preview Link Format:
https://media.cmlite.org/api/videos/preview/{token}
Scheduled Publishing
Overview
The scheduled publishing system uses BullMQ job queue with Redis backend to automatically publish/unpublish videos at specific times with timezone support.
Features
Publishing Options:
- Publish Now - Immediately set video to published state
- Schedule Publish - Set future publish date/time
- Schedule Unpublish - Auto-unpublish after period (optional)
- Timezone Support - 11 common timezones with auto-detection
- Calendar View - Visual overview of all upcoming schedules
Supported Timezones:
- UTC (Coordinated Universal Time)
- EST (Eastern Standard Time)
- CST (Central Standard Time)
- MST (Mountain Standard Time)
- PST (Pacific Standard Time)
- Toronto, Vancouver (Canada)
- GMT (London), CET (Paris)
- JST (Tokyo), AEDT (Sydney)
Using Scheduled Publishing
Schedule a Video:
- Click Schedule button on video card (clock icon)
- Toggle "Publish immediately" off
- Select timezone (defaults to your system timezone)
- Choose publish date/time
- Optionally enable "Auto-unpublish after period"
- Click "Schedule" button
View Scheduled Videos:
- Click "View Calendar" button in LibraryPage header
- Calendar shows badge counts for days with scheduled events
- Click a date to view scheduled publish/unpublish events
- Cancel individual schedules from the list
Schedule Badge:
- Appears on video cards when publish/unpublish is scheduled
- Shows time until scheduled action
- Clock icon with color coding:
- Green: Scheduled to publish
- Orange: Scheduled to unpublish
- Red: Overdue (failed to execute)
BullMQ Job Queue
Queue Configuration:
- Queue name:
video-schedules - Redis connection: Shared with email queue
- Job retry: 3 attempts with exponential backoff
- Job timeout: 30 seconds
Job Processing:
- Schedule created → Job added to queue with delayed timestamp
- Redis stores job until scheduled time
- Worker picks up job at scheduled time
- Updates video
isPublishedfield - Records execution in
VideoScheduleHistory - Removes job from queue
Monitoring:
See MediaJobsPage at /app/media/jobs for queue monitoring (coming soon).
Video Analytics
Overview
Comprehensive analytics tracking system that records views, watch time, user engagement, and traffic sources with privacy-focused design.
Metrics Tracked
Overview Statistics:
- Total Views - Number of times video was played
- Unique Viewers - Deduplicated viewers (by IP hash or user ID)
- Average Watch Time - Mean watch duration across all views
- Completion Rate - Percentage of viewers who watched ≥95%
- Total Watch Time - Cumulative watch time across all views
Per-View Data:
- IP address (SHA-256 hashed for privacy)
- User agent (truncated, version numbers removed)
- Referrer URL (traffic source)
- User ID (for logged-in users)
- Watch time in seconds
- Completion status (boolean)
Event Tracking:
- Play - Video started/resumed
- Pause - Video paused
- Seek - User skipped forward/backward (with timestamp)
- Complete - Video watched to 95%+
Privacy & GDPR Compliance
Data Protection Measures:
- IP Address Hashing - All IP addresses hashed with SHA-256 before storage
- User Agent Truncation - Version numbers and detailed info removed
- Anonymous Aggregation - Anonymous views separated from registered users
- 90-Day Retention - Configurable data retention policy (default: 90 days)
- Do Not Track - Respects DNT header (optional)
- User Opt-Out - Users can disable analytics tracking in settings
Compliance Features:
- No personally identifiable information (PII) stored for anonymous users
- Registered user tracking requires explicit consent
- GDPR Article 17 "Right to be forgotten" via reset analytics
- Transparent data collection disclosure
Analytics Dashboard
Quick Analytics Modal (accessible from video card):
- Overview stats (4 cards)
- Top referrers (up to 5 sources)
- Recent registered viewers (up to 10)
Detailed Analytics Modal (click Analytics button):
-
Overview Tab:
- 4 overview stat cards
- Total watch time card
- Top referrers table (sortable)
-
Charts Tab:
- Views over time (area chart, last 30 days)
- Traffic sources distribution (pie chart)
-
Viewers Tab:
- Full registered viewers table
- Sortable by watch time, completion status
- Filter by completed/partial views
Global Analytics Dashboard (/app/media/analytics):
- Platform-wide statistics (total videos, views, watch time)
- Average completion rate across all videos
- Top 10 videos by views or watch time (switchable)
- Ranking system with medal icons (🥇🥈🥉)
Tracking Implementation
Client-Side Tracking:
// Record view when modal opens
await mediaApi.post('/track/view', {
videoId: video.id,
referer: document.referrer || undefined,
});
// Record events
await mediaApi.post('/track/event', {
videoId: video.id,
viewId: viewId,
eventType: 'play', // or 'pause', 'seek', 'complete'
timestamp: videoElement.currentTime,
});
// Heartbeat every 10 seconds
setInterval(() => {
navigator.sendBeacon(
'/api/track/heartbeat',
JSON.stringify({ viewId, watchTimeSeconds: currentTime })
);
}, 10000);
Tracking Endpoints:
POST /track/view- Record video view startPOST /track/event- Record video event (play, pause, etc.)POST /track/heartbeat- Update watch time (high frequency)POST /track/batch- Batch event submission (coming soon)
Rate Limiting:
/track/view: 100 requests/minute per IP/track/event: 100 requests/minute per IP/track/heartbeat: 200 requests/minute per IP (higher for frequent updates)
Analytics Aggregation
Analytics are aggregated in real-time via the VideoAnalyticsService:
class VideoAnalyticsService {
async aggregateVideoAnalytics(videoId: number) {
// Aggregate from VideoView and VideoEvent tables
// Update Video model fields:
// - uniqueViewers
// - totalWatchTimeSeconds
// - averageWatchTimeSeconds
// - completionRate
}
}
Aggregation Triggers:
- After each video view completes
- On-demand via API endpoint
- Scheduled batch job (nightly, coming soon)
Architecture
System Design
Technology Stack:
- Backend: Fastify Media API (port 4100) with Prisma ORM
- Frontend: React + Ant Design + Zustand
- Job Queue: BullMQ with Redis
- Database: PostgreSQL 16 with Prisma migrations
- Charts: Recharts library
Database Schema
New Models:
model Video {
// ... existing fields ...
// Publishing
scheduledPublishAt DateTime?
scheduledUnpublishAt DateTime?
// Analytics
uniqueViewers Int @default(0)
totalWatchTimeSeconds Int @default(0)
averageWatchTimeSeconds Decimal @default(0) @db.Decimal(10, 2)
completionRate Decimal @default(0) @db.Decimal(5, 2)
// Relations
videoViews VideoView[]
videoEvents VideoEvent[]
}
model VideoView {
id Int @id @default(autoincrement())
videoId Int
userId Int?
ipAddress String? @db.VarChar(45) // SHA-256 hash
userAgent String? @db.Text
referer String? @db.Text
watchTimeSeconds Int @default(0)
completed Boolean @default(false)
createdAt DateTime @default(now())
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([videoId])
@@index([userId])
@@index([createdAt])
}
model VideoEvent {
id Int @id @default(autoincrement())
videoId Int
viewId Int?
eventType String @db.VarChar(50) // play, pause, seek, complete
timestamp Decimal @db.Decimal(10, 2) // Video timestamp in seconds
createdAt DateTime @default(now())
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
@@index([videoId])
@@index([viewId])
}
model VideoScheduleHistory {
id Int @id @default(autoincrement())
videoId Int
action String @db.VarChar(20) // 'publish' or 'unpublish'
scheduledFor DateTime
executedAt DateTime?
status String @db.VarChar(20) // 'pending', 'completed', 'failed', 'cancelled'
error String? @db.Text
scheduledByUserId Int
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
scheduledBy User @relation(fields: [scheduledByUserId], references: [id])
@@index([videoId])
@@index([scheduledFor])
@@index([status])
}
File Structure
Backend Services:
api/src/modules/media/
├── services/
│ ├── video-analytics.service.ts # Analytics aggregation
│ └── ffprobe.service.ts # Video metadata (existing)
├── routes/
│ ├── videos.routes.ts # Video CRUD (existing)
│ ├── video-actions.routes.ts # Quick actions (duplicate, preview link, etc.)
│ ├── video-schedule.routes.ts # Schedule management
│ ├── video-analytics.routes.ts # Analytics queries (admin)
│ └── video-tracking.routes.ts # Public tracking endpoints
└── db/
└── schema.ts # Drizzle schema (existing)
api/src/services/
└── video-schedule-queue.service.ts # BullMQ queue + worker
Frontend Components:
admin/src/components/media/
├── VideoCard.tsx # Enhanced with actions overlay
├── VideoActions.tsx # Action buttons component
├── QuickAnalyticsModal.tsx # Quick stats modal
├── SchedulePublishModal.tsx # Schedule picker with timezone
├── ScheduleCalendarModal.tsx # Calendar view
├── ScheduleBadge.tsx # Schedule status badge
├── VideoAnalyticsModal.tsx # Detailed analytics (3 tabs)
├── AnalyticsChart.tsx # Recharts wrapper
├── ViewersTable.tsx # Registered viewers table
└── VideoViewerModal.tsx # Enhanced with tracking
admin/src/pages/media/
├── LibraryPage.tsx # Enhanced with calendar button
└── AnalyticsDashboardPage.tsx # Global analytics dashboard
API Reference
Quick Actions Endpoints
Duplicate Video
POST /videos/:id/duplicate
Authorization: Bearer {admin_token}
Response:
{
"id": 123,
"title": "Original Title (Copy)",
"filename": "uuid-copy.mp4",
// ... other video fields
}
Generate Preview Link
GET /videos/:id/preview-link
Authorization: Bearer {admin_token}
Response:
{
"previewUrl": "https://media.cmlite.org/api/videos/preview/{jwt_token}",
"expiryHours": 24
}
Reset Analytics
POST /videos/:id/reset-analytics
Authorization: Bearer {admin_token}
Response:
{
"success": true,
"message": "Analytics reset successfully"
}
Get Video Analytics
GET /videos/:id/analytics
Authorization: Bearer {admin_token}
Query Params:
- startDate (optional): ISO date string
- endDate (optional): ISO date string
Response:
{
"overview": {
"totalViews": 1234,
"uniqueViewers": 567,
"averageWatchTime": 180.5,
"completionRate": 67.3,
"totalWatchTime": 123456
},
"topReferrers": [
{ "referer": "google.com", "count": 45 },
{ "referer": "facebook.com", "count": 23 }
],
"registeredViewers": [
{
"userId": 1,
"userName": "John Doe",
"userEmail": "john@example.com",
"watchTime": 300,
"completed": true
}
],
"viewsOverTime": [
{ "date": "2026-02-01", "count": 12 },
{ "date": "2026-02-02", "count": 18 }
]
}
Schedule Management Endpoints
Schedule Publish
POST /videos/:id/schedule-publish
Authorization: Bearer {admin_token}
Body:
{
"publishAt": "2026-02-20T14:00:00Z",
"timezone": "America/New_York"
}
Response:
{
"success": true,
"jobId": "schedule:publish:123:abc-def",
"scheduledFor": "2026-02-20T14:00:00Z"
}
Schedule Unpublish
POST /videos/:id/schedule-unpublish
Authorization: Bearer {admin_token}
Body:
{
"unpublishAt": "2026-03-01T00:00:00Z",
"timezone": "UTC"
}
Response:
{
"success": true,
"jobId": "schedule:unpublish:123:xyz-123",
"scheduledFor": "2026-03-01T00:00:00Z"
}
Cancel Schedule
DELETE /videos/:id/schedule/:action
Authorization: Bearer {admin_token}
Params:
- action: 'publish' or 'unpublish'
Response:
{
"success": true,
"message": "publish schedule cancelled"
}
Get Upcoming Schedules
GET /videos/schedules/upcoming
Authorization: Bearer {admin_token}
Query Params:
- limit (optional): default 100
Response:
{
"schedules": [
{
"jobId": "schedule:publish:123:abc",
"videoId": 123,
"videoTitle": "My Video",
"action": "publish",
"scheduledFor": "2026-02-20T14:00:00Z",
"status": "pending"
}
]
}
Get Schedule History
GET /videos/:id/schedule-history
Authorization: Bearer {admin_token}
Response:
{
"history": [
{
"id": 1,
"action": "publish",
"scheduledFor": "2026-02-15T10:00:00Z",
"executedAt": "2026-02-15T10:00:03Z",
"status": "completed",
"scheduledBy": "admin@example.com"
}
]
}
Analytics Tracking Endpoints (Public)
Record View
POST /track/view
Content-Type: application/json
Body:
{
"videoId": 123,
"referer": "https://example.com" // optional
}
Response:
{
"viewId": 456
}
Record Event
POST /track/event
Content-Type: application/json
Body:
{
"videoId": 123,
"viewId": 456, // optional
"eventType": "play", // 'play', 'pause', 'seek', 'complete'
"timestamp": 45.5
}
Response:
{
"success": true
}
Update Watch Time (Heartbeat)
POST /track/heartbeat
Content-Type: application/json
Body:
{
"viewId": 456,
"watchTimeSeconds": 120
}
Response:
{
"success": true
}
Analytics Query Endpoints (Admin)
Get Top Videos
GET /videos/analytics/top
Authorization: Bearer {admin_token}
Query Params:
- metric: 'views' or 'watchTime'
- limit: default 10
Response:
{
"videos": [
{
"id": 123,
"title": "Popular Video",
"value": 1234 // views or watch time based on metric
}
]
}
Get Analytics Overview
GET /videos/analytics/overview
Authorization: Bearer {admin_token}
Response:
{
"totalVideos": 50,
"totalViews": 12345,
"totalWatchTimeSeconds": 567890,
"averageCompletionRate": 65.4
}
Security
Authorization
Admin-Only Endpoints: All quick action, schedule management, and analytics query endpoints require admin role:
requireAdminRolemiddleware (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN)
Public Tracking Endpoints:
- No authentication required
- Rate limited (100-200 req/min per IP)
- Optional authentication via
optionalAuthmiddleware (tracks user ID if logged in)
Rate Limiting
Tracking Endpoints:
{
'/track/view': { max: 100, windowMs: 60000 }, // 100/min
'/track/event': { max: 100, windowMs: 60000 }, // 100/min
'/track/heartbeat': { max: 200, windowMs: 60000 }, // 200/min (higher for frequent updates)
}
Admin Endpoints:
- Covered by global admin rate limits (500 req/min)
Preview Link Security
JWT Token Structure:
{
videoId: number,
exp: number, // 24 hours from generation
iat: number
}
Validation:
- JWT signature verification
- Expiration check (401 if expired)
- Video existence check (404 if deleted)
- Single-use recommended (no enforcement yet)
Environment Configuration:
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
JWT_ACCESS_SECRET=your_secret_here # Used for signing preview tokens
Privacy Protection
IP Address Hashing:
import { createHash } from 'crypto';
function hashIpAddress(ipAddress: string): string {
return createHash('sha256')
.update(ipAddress)
.digest('hex');
}
User Agent Truncation:
function truncateUserAgent(userAgent: string): string {
// Remove version numbers: "Chrome/91.0.4472.124" → "Chrome"
return userAgent
.replace(/\/[\d.]+/g, '')
.substring(0, 200);
}
Data Retention:
// Scheduled cleanup (nightly)
async function cleanupOldAnalytics() {
const retentionDays = parseInt(process.env.VIDEO_ANALYTICS_RETENTION_DAYS || '90');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
await prisma.videoView.deleteMany({
where: { createdAt: { lt: cutoffDate } }
});
await prisma.videoEvent.deleteMany({
where: { createdAt: { lt: cutoffDate } }
});
}
Environment Variables
Add to .env:
# Video Analytics
VIDEO_ANALYTICS_RETENTION_DAYS=90
VIDEO_ANALYTICS_IP_HASHING_ENABLED=true
# Video Scheduling
VIDEO_SCHEDULE_DEFAULT_TIMEZONE=UTC
VIDEO_SCHEDULE_NOTIFICATION_ENABLED=true
# Preview Links
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
Troubleshooting
Scheduled Publish Not Executing
Check BullMQ Queue:
# View queue status
docker compose exec media-api npm run queue:status
# Retry failed jobs
docker compose exec media-api npm run queue:retry
Check Redis Connection:
docker compose logs redis
docker compose exec redis redis-cli ping
Check Schedule History:
SELECT * FROM video_schedule_history
WHERE status = 'failed'
ORDER BY scheduled_for DESC
LIMIT 10;
Analytics Not Tracking
Check Rate Limits:
# View Redis rate limit keys
docker compose exec redis redis-cli --scan --pattern "rl:*"
# Check remaining requests
docker compose exec redis redis-cli GET "rl:track-view:192.168.1.100"
Check Network Tab:
- Open browser DevTools → Network
- Filter by
/track/ - Verify 200 OK responses
- Check for CORS errors
Verify Tracking Endpoints:
curl -X POST http://localhost:4100/api/track/view \
-H "Content-Type: application/json" \
-d '{"videoId": 1}'
Preview Link Expired
Regenerate Link:
- Navigate to LibraryPage
- Hover over video card
- Click "More Actions" → "Generate Preview Link"
- New 24-hour link generated
Adjust Expiry Time:
# In .env
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=48 # 2 days
Future Enhancements
Coming Soon:
- Download Functionality - Direct video file downloads
- Thumbnail Generation - Auto-generate thumbnails from video frames
- Batch Event Submission -
/track/batchendpoint for bulk events - Scheduled Reports - Email weekly analytics summaries
- A/B Testing - Compare multiple video versions
- Heatmaps - Visual representation of drop-off points
- Export Analytics - CSV/PDF export for reports
- Custom Dashboards - User-configurable analytics views
Support
Documentation:
- Main docs:
CLAUDE.md - Analytics guide:
VIDEO_ANALYTICS_GUIDE.md - API architecture:
api/src/modules/media/README.md
Issues: Report bugs or request features at: https://github.com/anthropics/changemaker-lite/issues
Questions: Contact the development team or check the wiki for FAQs.