feat: add video playback progress reporting

This commit is contained in:
Codex
2026-06-29 20:38:12 +08:00
parent 38db867d58
commit f6710ace8b
9 changed files with 223 additions and 3 deletions

View File

@@ -199,6 +199,14 @@ function actorLabelSql(alias: string) {
return `coalesce(nullif(${alias}.name, ''), nullif(${alias}.username, ''), concat('user:', left(${alias}.id::text, 8)))`;
}
function jsonbIntegerExpr(pathSql: string) {
return `case when ${pathSql} ~ '^\\d+$' then (${pathSql})::integer else 0 end`;
}
function jsonbRateExpr(pathSql: string) {
return `case when ${pathSql} ~ '^(0(\\.\\d+)?|1(\\.0+)?)$' then (${pathSql})::numeric else null end`;
}
export async function mediaAnalyticsSummaryRoute(ctx: RequestContext) {
const auth = await requireTenantContentPermission(ctx, 'content:analytics:read');
const range = parseRange(stringParam(ctx, 'timeRange'));
@@ -250,6 +258,8 @@ export async function mediaAnalyticsSummaryRoute(ctx: RequestContext) {
count(*) filter (where e.access_mode = 'svip')::integer as "svipPlays",
count(*) filter (where e.access_mode = 'video_quota')::integer as "quotaPlays",
coalesce(sum(e.consumed_quota), 0)::integer as "consumedQuota",
coalesce(sum(${jsonbIntegerExpr("e.metadata #>> '{playback,watchedSeconds}'")}), 0)::integer as "watchedSeconds",
round(avg(nullif(${jsonbRateExpr("e.metadata #>> '{playback,completionRate}'")}, 0)), 4) as "avgCompletionRate",
count(distinct e.user_id)::integer as "uniqueUsers",
count(*) filter (where e.metadata #>> '{watermark,traceId}' is not null)::integer as "watermarkEvents"
from public.video_play_events e
@@ -289,7 +299,10 @@ export async function mediaAnalyticsSummaryRoute(ctx: RequestContext) {
count(*)::integer as "totalEvents",
count(distinct e.user_id)::integer as "uniqueUsers",
coalesce(sum(e.consumed_quota), 0)::integer as "consumedQuota",
coalesce(sum(${jsonbIntegerExpr("e.metadata #>> '{playback,watchedSeconds}'")}), 0)::integer as "watchedSeconds",
round(avg(nullif(${jsonbRateExpr("e.metadata #>> '{playback,completionRate}'")}, 0)), 4) as "avgCompletionRate",
count(*) filter (where e.status = 'issued')::integer as "issuedEvents",
count(*) filter (where e.status = 'completed')::integer as "completedEvents",
max(e.created_at) as "lastPlayAt"
from public.video_play_events e
left join public.video_explanations v on v.tenant_id = e.tenant_id and v.id = e.video_id
@@ -382,6 +395,8 @@ export async function mediaAnalyticsSummaryRoute(ctx: RequestContext) {
svipPlays: intValue(videoTotals?.svipPlays),
quotaPlays: intValue(videoTotals?.quotaPlays),
consumedQuota: intValue(videoTotals?.consumedQuota),
watchedSeconds: intValue(videoTotals?.watchedSeconds),
avgCompletionRate: numberValue(videoTotals?.avgCompletionRate),
uniqueUsers: intValue(videoTotals?.uniqueUsers),
watermarkEvents: intValue(videoTotals?.watermarkEvents),
},
@@ -452,6 +467,7 @@ export async function mediaVideoPlayEventsRoute(ctx: RequestContext) {
e.user_id as "userId", ${actorLabelSql('u')} as "userLabel",
e.status, e.access_mode as "accessMode",
e.consumed_quota as "consumedQuota",
e.metadata -> 'playback' as playback,
e.signed_url_expires_at as "signedUrlExpiresAt",
e.ip_address as "ipAddress", e.user_agent as "userAgent",
e.metadata ->> 'signatureMode' as "signatureMode",

View File

@@ -1,9 +1,10 @@
import type { RouteDefinition } from '../../core/router.js';
import { questionVideosBatchRoute, questionVideosRoute, videoPlaybackRoute, videoSearchRoute } from './routes.js';
import { questionVideosBatchRoute, questionVideosRoute, videoPlaybackRoute, videoProgressRoute, videoSearchRoute } from './routes.js';
export const videoRoutes: RouteDefinition[] = [
['GET', '/api/questions/videos', questionVideosRoute],
['POST', '/api/questions/videos/batch', questionVideosBatchRoute],
['GET', '/api/videos/search', videoSearchRoute],
['POST', '/api/videos/play', videoPlaybackRoute],
['POST', '/api/videos/progress', videoProgressRoute],
];

View File

@@ -6,6 +6,7 @@ import {
optionalString,
optionalStringArray,
readJsonBody,
requiredString,
stringParam,
tenantIdFrom,
userIdFrom,
@@ -77,6 +78,12 @@ interface QuotaAccountRow {
used_quota: number;
}
interface VideoPlayEventRow {
id: string;
status: 'issued' | 'started' | 'completed' | 'expired' | 'revoked';
metadata: Record<string, unknown>;
}
function videoSelectSql() {
return `
select qv.question_id as "questionId", qv.video_type as "videoType",
@@ -110,6 +117,22 @@ function hashPlayToken(token: string) {
return crypto.createHash('sha256').update(token).digest('hex');
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function boundedInt(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(Math.trunc(parsed), min), max);
}
function progressEventType(value: unknown) {
const eventType = typeof value === 'string' ? value.trim() : '';
if (eventType === 'start' || eventType === 'heartbeat' || eventType === 'complete') return eventType;
throw new HttpError(400, 'eventType must be start, heartbeat, or complete', 'INVALID_VIDEO_PROGRESS_EVENT');
}
async function activeSvipEntitlement(tenantId: string, userId: string, video: VideoPlaybackRow) {
const now = new Date().toISOString();
return queryOne<EntitlementAccessRow>(
@@ -454,3 +477,92 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
...playback,
};
}
export async function videoProgressRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const userId = await userIdFrom(ctx);
const playToken = requiredString(body, 'playToken');
if (!playToken.startsWith('vp_') || playToken.length < 24 || playToken.length > 160) {
throw new HttpError(400, 'Invalid play token', 'INVALID_PLAY_TOKEN');
}
const eventType = progressEventType(body.eventType);
const progressSeconds = boundedInt(body.progressSeconds, 0, 0, 86_400);
const durationSeconds = boundedInt(body.durationSeconds, 0, 0, 86_400);
const watchedSecondsInput = boundedInt(body.watchedSeconds ?? progressSeconds, progressSeconds, 0, 86_400);
const completedByProgress = durationSeconds > 0 && progressSeconds >= Math.floor(durationSeconds * 0.9);
const nextStatus = eventType === 'complete' || completedByProgress ? 'completed' : 'started';
const tokenHash = hashPlayToken(playToken);
const item = await transaction(async client => {
const currentResult = await client.query<VideoPlayEventRow>(
`
select id, status, metadata
from public.video_play_events
where tenant_id = $1
and user_id = $2
and play_token_hash = $3
limit 1
for update
`,
[tenantId, userId, tokenHash],
);
const current = currentResult.rows[0];
if (!current) throw new HttpError(404, 'Video play event not found', 'VIDEO_PLAY_EVENT_NOT_FOUND');
if (current.status === 'revoked') {
throw new HttpError(409, 'Video play event has been revoked', 'VIDEO_PLAY_EVENT_REVOKED');
}
if (current.status === 'expired') {
throw new HttpError(409, 'Video play event has expired', 'VIDEO_PLAY_EVENT_EXPIRED');
}
const nowIso = new Date().toISOString();
const metadata = objectValue(current.metadata);
const previousPlayback = objectValue(metadata.playback);
const previousWatched = boundedInt(previousPlayback.watchedSeconds, 0, 0, 86_400);
const watchedSeconds = Math.max(previousWatched, watchedSecondsInput, progressSeconds);
const completedAt = nextStatus === 'completed'
? String(previousPlayback.completedAt || nowIso)
: (typeof previousPlayback.completedAt === 'string' ? previousPlayback.completedAt : null);
const startedAt = typeof previousPlayback.startedAt === 'string' ? previousPlayback.startedAt : nowIso;
const finalStatus = current.status === 'completed' ? 'completed' : nextStatus;
const completionRate = durationSeconds > 0 ? Number(Math.min(progressSeconds / durationSeconds, 1).toFixed(4)) : null;
const nextMetadata = {
...metadata,
playback: {
...previousPlayback,
startedAt,
completedAt,
lastEventType: eventType,
lastProgressSeconds: progressSeconds,
durationSeconds: durationSeconds || previousPlayback.durationSeconds || null,
watchedSeconds,
completionRate,
updatedAt: nowIso,
},
};
const updated = await client.query(
`
update public.video_play_events
set status = $4,
metadata = $5::jsonb,
updated_at = now()
where tenant_id = $1
and user_id = $2
and play_token_hash = $3
returning id, video_id as "videoId", question_id as "questionId",
status, access_mode as "accessMode",
consumed_quota as "consumedQuota",
metadata -> 'playback' as playback,
updated_at as "updatedAt"
`,
[tenantId, userId, tokenHash, finalStatus, JSON.stringify(nextMetadata)],
);
return updated.rows[0];
});
return { item };
}