353 lines
13 KiB
JavaScript
353 lines
13 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.videoAnalyticsService = exports.VideoAnalyticsService = void 0;
|
|
const database_1 = require("../../../config/database");
|
|
const logger_1 = require("../../../utils/logger");
|
|
const crypto_1 = require("crypto");
|
|
const library_1 = require("@prisma/client/runtime/library");
|
|
class VideoAnalyticsService {
|
|
/**
|
|
* Hash IP address for privacy (SHA-256)
|
|
*/
|
|
hashIpAddress(ipAddress) {
|
|
return (0, crypto_1.createHash)('sha256').update(ipAddress).digest('hex');
|
|
}
|
|
/**
|
|
* Hash user agent for privacy (SHA-256, truncate version numbers first)
|
|
*/
|
|
hashUserAgent(userAgent) {
|
|
// Truncate version numbers for better privacy
|
|
const truncated = userAgent
|
|
.replace(/\d+\.\d+\.\d+/g, 'X.X.X') // Remove version numbers
|
|
.slice(0, 200); // Limit length
|
|
return (0, crypto_1.createHash)('sha256').update(truncated).digest('hex');
|
|
}
|
|
/**
|
|
* Record a new video view
|
|
*/
|
|
async recordView(params) {
|
|
const { videoId, userId, ipAddress, userAgent, referer } = params;
|
|
try {
|
|
const view = await database_1.prisma.videoView.create({
|
|
data: {
|
|
videoId,
|
|
userId: userId || null,
|
|
ipAddressHash: ipAddress ? this.hashIpAddress(ipAddress) : null,
|
|
userAgentHash: userAgent ? this.hashUserAgent(userAgent) : null,
|
|
referer: referer || null,
|
|
watchTimeSeconds: 0,
|
|
completed: false,
|
|
},
|
|
});
|
|
// Increment view counter on video
|
|
await database_1.prisma.video.update({
|
|
where: { id: videoId },
|
|
data: {
|
|
viewCount: {
|
|
increment: 1,
|
|
},
|
|
},
|
|
});
|
|
logger_1.logger.info(`Recorded view for video ${videoId}`, { viewId: view.id });
|
|
return view.id;
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.error('Failed to record video view', { error, videoId });
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* Record a video event (play, pause, seek, complete)
|
|
*/
|
|
async recordEvent(params) {
|
|
const { videoId, viewId, eventType, timestamp } = params;
|
|
try {
|
|
await database_1.prisma.videoEvent.create({
|
|
data: {
|
|
videoId,
|
|
viewId: viewId || null,
|
|
eventType,
|
|
timestamp: new library_1.Decimal(timestamp),
|
|
},
|
|
});
|
|
// If complete event, mark view as completed and increment finishCount
|
|
if (eventType === 'complete' && viewId) {
|
|
await database_1.prisma.videoView.update({
|
|
where: { id: viewId },
|
|
data: { completed: true },
|
|
});
|
|
await database_1.prisma.video.update({
|
|
where: { id: videoId },
|
|
data: {
|
|
finishCount: {
|
|
increment: 1,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.error('Failed to record video event', { error, videoId, eventType });
|
|
// Don't throw - we don't want analytics failures to break playback
|
|
}
|
|
}
|
|
/**
|
|
* Update watch time for a view (called periodically during playback)
|
|
*/
|
|
async updateWatchTime(viewId, watchTimeSeconds) {
|
|
try {
|
|
await database_1.prisma.videoView.update({
|
|
where: { id: viewId },
|
|
data: {
|
|
watchTimeSeconds,
|
|
updatedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.error('Failed to update watch time', { error, viewId });
|
|
// Don't throw - we don't want analytics failures to break playback
|
|
}
|
|
}
|
|
/**
|
|
* Aggregate analytics for a video
|
|
*/
|
|
async aggregateVideoAnalytics(videoId) {
|
|
try {
|
|
const [views, uniqueViewers, totalWatchTime, avgWatchTime, completions] = await Promise.all([
|
|
// Total views
|
|
database_1.prisma.videoView.count({
|
|
where: { videoId },
|
|
}),
|
|
// Unique viewers (registered users only)
|
|
database_1.prisma.videoView.findMany({
|
|
where: {
|
|
videoId,
|
|
userId: { not: null },
|
|
},
|
|
distinct: ['userId'],
|
|
select: { userId: true },
|
|
}).then(views => views.length),
|
|
// Total watch time
|
|
database_1.prisma.videoView.aggregate({
|
|
where: { videoId },
|
|
_sum: {
|
|
watchTimeSeconds: true,
|
|
},
|
|
}),
|
|
// Average watch time
|
|
database_1.prisma.videoView.aggregate({
|
|
where: { videoId },
|
|
_avg: {
|
|
watchTimeSeconds: true,
|
|
},
|
|
}),
|
|
// Completions
|
|
database_1.prisma.videoView.count({
|
|
where: {
|
|
videoId,
|
|
completed: true,
|
|
},
|
|
}),
|
|
]);
|
|
// Get video duration for completion rate calculation
|
|
const video = await database_1.prisma.video.findUnique({
|
|
where: { id: videoId },
|
|
select: { durationSeconds: true },
|
|
});
|
|
const completionRate = views > 0 && video?.durationSeconds
|
|
? new library_1.Decimal((completions / views) * 100)
|
|
: new library_1.Decimal(0);
|
|
// Update video analytics fields
|
|
await database_1.prisma.video.update({
|
|
where: { id: videoId },
|
|
data: {
|
|
uniqueViewers,
|
|
totalWatchTimeSeconds: totalWatchTime._sum.watchTimeSeconds || 0,
|
|
averageWatchTimeSeconds: avgWatchTime._avg.watchTimeSeconds
|
|
? new library_1.Decimal(avgWatchTime._avg.watchTimeSeconds)
|
|
: new library_1.Decimal(0),
|
|
completionRate,
|
|
},
|
|
});
|
|
logger_1.logger.info(`Aggregated analytics for video ${videoId}`, {
|
|
views,
|
|
uniqueViewers,
|
|
completionRate: completionRate.toNumber(),
|
|
});
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.error('Failed to aggregate video analytics', { error, videoId });
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* Get detailed analytics for a video
|
|
*/
|
|
async getVideoAnalytics(videoId, startDate, endDate) {
|
|
const where = { videoId };
|
|
if (startDate || endDate) {
|
|
where.createdAt = {};
|
|
if (startDate)
|
|
where.createdAt.gte = startDate;
|
|
if (endDate)
|
|
where.createdAt.lte = endDate;
|
|
}
|
|
try {
|
|
// Overview stats
|
|
const video = await database_1.prisma.video.findUnique({
|
|
where: { id: videoId },
|
|
select: {
|
|
viewCount: true,
|
|
uniqueViewers: true,
|
|
totalWatchTimeSeconds: true,
|
|
averageWatchTimeSeconds: true,
|
|
completionRate: true,
|
|
},
|
|
});
|
|
if (!video) {
|
|
throw new Error('Video not found');
|
|
}
|
|
// Views over time (last 30 days, grouped by day)
|
|
const thirtyDaysAgo = new Date();
|
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
const viewsByDay = await database_1.prisma.$queryRaw `
|
|
SELECT
|
|
DATE(created_at) as date,
|
|
COUNT(*) as count
|
|
FROM video_views
|
|
WHERE video_id = ${videoId}
|
|
AND created_at >= ${thirtyDaysAgo}
|
|
GROUP BY DATE(created_at)
|
|
ORDER BY DATE(created_at) ASC
|
|
`;
|
|
// Top referrers
|
|
const topReferrers = await database_1.prisma.videoView.groupBy({
|
|
by: ['referer'],
|
|
where: {
|
|
videoId,
|
|
referer: { not: null },
|
|
},
|
|
_count: {
|
|
referer: true,
|
|
},
|
|
orderBy: {
|
|
_count: {
|
|
referer: 'desc',
|
|
},
|
|
},
|
|
take: 10,
|
|
});
|
|
// Registered viewers
|
|
const registeredViewers = await database_1.prisma.videoView.findMany({
|
|
where: {
|
|
videoId,
|
|
userId: { not: null },
|
|
},
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
take: 100,
|
|
});
|
|
return {
|
|
overview: {
|
|
totalViews: video.viewCount,
|
|
uniqueViewers: video.uniqueViewers,
|
|
averageWatchTime: video.averageWatchTimeSeconds.toNumber(),
|
|
completionRate: video.completionRate.toNumber(),
|
|
totalWatchTime: video.totalWatchTimeSeconds,
|
|
},
|
|
viewsOverTime: viewsByDay.map((row) => ({
|
|
date: row.date,
|
|
count: Number(row.count),
|
|
})),
|
|
topReferrers: topReferrers.map((item) => ({
|
|
referer: item.referer || 'Direct',
|
|
count: item._count.referer,
|
|
})),
|
|
registeredViewers: registeredViewers.map((view) => ({
|
|
userId: view.userId,
|
|
userName: view.user?.name || null,
|
|
userEmail: view.user?.email || '',
|
|
watchTime: view.watchTimeSeconds,
|
|
completed: view.completed,
|
|
viewedAt: view.createdAt,
|
|
})),
|
|
};
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.error('Failed to get video analytics', { error, videoId });
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* Get top videos by a metric
|
|
*/
|
|
async getTopVideos(metric, limit = 10) {
|
|
try {
|
|
const orderBy = metric === 'views' ? { viewCount: 'desc' } : { totalWatchTimeSeconds: 'desc' };
|
|
const videos = await database_1.prisma.video.findMany({
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
viewCount: true,
|
|
totalWatchTimeSeconds: true,
|
|
},
|
|
orderBy,
|
|
take: limit,
|
|
});
|
|
return videos.map((video) => ({
|
|
id: video.id,
|
|
title: video.title,
|
|
value: metric === 'views' ? video.viewCount : video.totalWatchTimeSeconds,
|
|
}));
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.error('Failed to get top videos', { error, metric });
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* Reset analytics for a video
|
|
*/
|
|
async resetAnalytics(videoId) {
|
|
try {
|
|
// Delete all views and events
|
|
await database_1.prisma.videoView.deleteMany({
|
|
where: { videoId },
|
|
});
|
|
await database_1.prisma.videoEvent.deleteMany({
|
|
where: { videoId },
|
|
});
|
|
// Reset counters
|
|
await database_1.prisma.video.update({
|
|
where: { id: videoId },
|
|
data: {
|
|
viewCount: 0,
|
|
uniqueViewers: 0,
|
|
totalWatchTimeSeconds: 0,
|
|
averageWatchTimeSeconds: new library_1.Decimal(0),
|
|
completionRate: new library_1.Decimal(0),
|
|
finishCount: 0,
|
|
},
|
|
});
|
|
logger_1.logger.info(`Reset analytics for video ${videoId}`);
|
|
}
|
|
catch (error) {
|
|
logger_1.logger.error('Failed to reset analytics', { error, videoId });
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
exports.VideoAnalyticsService = VideoAnalyticsService;
|
|
exports.videoAnalyticsService = new VideoAnalyticsService();
|
|
//# sourceMappingURL=video-analytics.service.js.map
|