New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it
1143 lines
36 KiB
TypeScript
1143 lines
36 KiB
TypeScript
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||
import type {
|
||
TAssetAttach,
|
||
TAssetCreateUpload,
|
||
TAssetDetach,
|
||
TAssetExportQuery,
|
||
TAssetListQuery,
|
||
TAssetStorageQuery,
|
||
TAssetUpdate,
|
||
TBatchPresignHls,
|
||
TBatchPresignHls1080,
|
||
TFinalizeHls1080,
|
||
TFinalizeHlsAsset,
|
||
TInitHlsAsset,
|
||
THls1080Status,
|
||
TYouTubeMetadataQuery
|
||
} from '@cio/utils/validation/assets';
|
||
import type { TTranscriptResponse, TUpdateTranscript } from '@cio/utils/validation/media';
|
||
import {
|
||
countAssetUsagesByAsset,
|
||
createAssetUsage,
|
||
createHlsAssetPlaceholder,
|
||
createOrGetAssetByStorageKey,
|
||
deleteAsset,
|
||
deleteAssetUsage,
|
||
finalizeHlsAsset as finalizeHlsAssetQuery,
|
||
finalizeHls1080Rendition,
|
||
getAssetById,
|
||
getAssetStorageSummaryByOrg,
|
||
listAssetsByOrg,
|
||
listAssetsForExport,
|
||
listAssetUsagesByAsset,
|
||
updateAsset
|
||
} from '@cio/db/queries/assets';
|
||
import { getMediaTranscriptByAsset, updateMediaTranscriptContent } from '@cio/db/queries';
|
||
|
||
import {
|
||
generateTranscriptVttPresignedUrl,
|
||
generateUploadPresignedUrl,
|
||
TRANSCRIPT_VTT_PRESIGN_SECONDS
|
||
} from '../../utils/s3';
|
||
import { getS3Client, getStorageConfig } from '../../config/storage';
|
||
|
||
const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/;
|
||
|
||
type YouTubeOEmbedResponse = {
|
||
title?: unknown;
|
||
thumbnail_url?: unknown;
|
||
};
|
||
|
||
type YouTubeWatchPageMetadata = {
|
||
title: string | null;
|
||
durationSeconds: number | null;
|
||
thumbnailUrl: string | null;
|
||
};
|
||
|
||
function assertAssetExists<T>(asset: T | null): T {
|
||
if (!asset) {
|
||
throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
|
||
}
|
||
|
||
return asset;
|
||
}
|
||
|
||
function normalizeUrl(url: string): URL | null {
|
||
const trimmed = url.trim();
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return new URL(trimmed);
|
||
} catch {
|
||
try {
|
||
return new URL(`https://${trimmed}`);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
function extractYouTubeVideoId(url: string): string | null {
|
||
const parsedUrl = normalizeUrl(url);
|
||
if (!parsedUrl) {
|
||
return null;
|
||
}
|
||
|
||
const host = parsedUrl.hostname.toLowerCase();
|
||
let videoId: string | null = null;
|
||
|
||
if (host === 'youtu.be' || host === 'www.youtu.be') {
|
||
const [pathSegment] = parsedUrl.pathname.split('/').filter(Boolean);
|
||
videoId = pathSegment ?? null;
|
||
} else if (
|
||
host === 'youtube.com' ||
|
||
host === 'www.youtube.com' ||
|
||
host === 'm.youtube.com' ||
|
||
host === 'music.youtube.com' ||
|
||
host === 'youtube-nocookie.com' ||
|
||
host === 'www.youtube-nocookie.com'
|
||
) {
|
||
if (parsedUrl.pathname === '/watch') {
|
||
videoId = parsedUrl.searchParams.get('v');
|
||
} else {
|
||
const [firstSegment, secondSegment] = parsedUrl.pathname.split('/').filter(Boolean);
|
||
if (firstSegment === 'embed' || firstSegment === 'shorts' || firstSegment === 'live') {
|
||
videoId = secondSegment ?? null;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!videoId || !YOUTUBE_VIDEO_ID_REGEX.test(videoId)) {
|
||
return null;
|
||
}
|
||
|
||
return videoId;
|
||
}
|
||
|
||
function decodeHtmlEntities(value: string): string {
|
||
return value
|
||
.replaceAll('&', '&')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll(''', "'")
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>');
|
||
}
|
||
|
||
function extractMetaContent(html: string, pattern: RegExp): string | null {
|
||
const match = html.match(pattern);
|
||
const value = match?.[1];
|
||
if (!value) {
|
||
return null;
|
||
}
|
||
|
||
const decoded = decodeHtmlEntities(value.trim());
|
||
return decoded.length ? decoded : null;
|
||
}
|
||
|
||
function extractDurationSeconds(html: string): number | null {
|
||
const lengthSecondsMatch = html.match(/"lengthSeconds":"(\d+)"/);
|
||
if (lengthSecondsMatch?.[1]) {
|
||
const parsed = Number.parseInt(lengthSecondsMatch[1], 10);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
const approxDurationMsMatch = html.match(/"approxDurationMs":"(\d+)"/);
|
||
if (approxDurationMsMatch?.[1]) {
|
||
const parsed = Number.parseInt(approxDurationMsMatch[1], 10);
|
||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||
return Math.round(parsed / 1000);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
async function fetchYouTubeOEmbed(videoUrl: string): Promise<{ title: string | null; thumbnailUrl: string | null }> {
|
||
try {
|
||
const response = await fetch(`https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(videoUrl)}`);
|
||
if (!response.ok) {
|
||
return {
|
||
title: null,
|
||
thumbnailUrl: null
|
||
};
|
||
}
|
||
|
||
const data = (await response.json()) as YouTubeOEmbedResponse;
|
||
const title = typeof data.title === 'string' && data.title.trim().length ? data.title.trim() : null;
|
||
const thumbnailUrl =
|
||
typeof data.thumbnail_url === 'string' && data.thumbnail_url.trim().length ? data.thumbnail_url.trim() : null;
|
||
|
||
return {
|
||
title,
|
||
thumbnailUrl
|
||
};
|
||
} catch {
|
||
return {
|
||
title: null,
|
||
thumbnailUrl: null
|
||
};
|
||
}
|
||
}
|
||
|
||
async function fetchYouTubeWatchPageMetadata(videoId: string): Promise<YouTubeWatchPageMetadata> {
|
||
try {
|
||
const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`);
|
||
if (!response.ok) {
|
||
return {
|
||
title: null,
|
||
durationSeconds: null,
|
||
thumbnailUrl: null
|
||
};
|
||
}
|
||
|
||
const html = await response.text();
|
||
const title =
|
||
extractMetaContent(html, /<meta\s+property="og:title"\s+content="([^"]+)"/i) ??
|
||
extractMetaContent(html, /<meta\s+name="title"\s+content="([^"]+)"/i);
|
||
const thumbnailUrl = extractMetaContent(html, /<meta\s+property="og:image"\s+content="([^"]+)"/i);
|
||
const durationSeconds = extractDurationSeconds(html);
|
||
|
||
return {
|
||
title,
|
||
durationSeconds,
|
||
thumbnailUrl
|
||
};
|
||
} catch {
|
||
return {
|
||
title: null,
|
||
durationSeconds: null,
|
||
thumbnailUrl: null
|
||
};
|
||
}
|
||
}
|
||
|
||
export async function listOrganizationAssetsService(orgId: string, query: TAssetListQuery) {
|
||
try {
|
||
return await listAssetsByOrg(orgId, query);
|
||
} catch (error) {
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to list assets',
|
||
ErrorCodes.ASSET_LIST_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function createAssetFromUploadService(orgId: string, profileId: string, data: TAssetCreateUpload) {
|
||
try {
|
||
const asset = await createOrGetAssetByStorageKey({
|
||
organizationId: orgId,
|
||
kind: data.kind,
|
||
provider: data.provider,
|
||
storageProvider: data.storageProvider,
|
||
storageKey: data.storageKey ?? null,
|
||
sourceUrl: data.sourceUrl ?? null,
|
||
mimeType: data.mimeType ?? null,
|
||
byteSize: data.byteSize ?? null,
|
||
checksum: data.checksum ?? null,
|
||
title: data.title ?? null,
|
||
description: data.description ?? null,
|
||
thumbnailUrl: data.thumbnailUrl ?? null,
|
||
durationSeconds: data.durationSeconds ?? null,
|
||
aspectRatio: data.aspectRatio ?? null,
|
||
isExternal: data.isExternal,
|
||
status: 'active',
|
||
metadata: data.metadata ?? {},
|
||
createdByProfileId: profileId
|
||
});
|
||
|
||
// Fire-and-forget: enqueue lesson-video post-processing for new uploads.
|
||
// Errors are logged but never block the asset create response.
|
||
if (asset.kind === 'video' && asset.provider === 'upload' && asset.storageKey) {
|
||
void enqueueMediaPostProcessingForAsset({
|
||
organizationId: orgId,
|
||
assetId: asset.id,
|
||
storageKey: asset.storageKey,
|
||
triggeredByProfileId: profileId
|
||
});
|
||
}
|
||
|
||
return asset;
|
||
} catch (error) {
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to create asset',
|
||
ErrorCodes.ASSET_CREATE_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
async function enqueueMediaPostProcessingForAsset(input: {
|
||
organizationId: string;
|
||
assetId: string;
|
||
storageKey: string;
|
||
triggeredByProfileId: string;
|
||
}): Promise<void> {
|
||
try {
|
||
const { startMediaJob } = await import('../jobs/media-jobs');
|
||
await startMediaJob({
|
||
organizationId: input.organizationId,
|
||
assetId: input.assetId,
|
||
storageKey: input.storageKey,
|
||
triggeredByProfileId: input.triggeredByProfileId,
|
||
withTranscription: Boolean(process.env.OPENAI_API_KEY)
|
||
});
|
||
} catch (error) {
|
||
console.error('enqueueMediaPostProcessingForAsset failed:', error);
|
||
}
|
||
}
|
||
|
||
export async function getAssetService(orgId: string, assetId: string) {
|
||
try {
|
||
const asset = await getAssetById(assetId, orgId);
|
||
return assertAssetExists(asset);
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
throw error;
|
||
}
|
||
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to fetch asset',
|
||
ErrorCodes.ASSET_FETCH_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function updateAssetService(orgId: string, assetId: string, data: TAssetUpdate) {
|
||
try {
|
||
const updated = await updateAsset(assetId, orgId, data);
|
||
return assertAssetExists(updated);
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
throw error;
|
||
}
|
||
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to update asset',
|
||
ErrorCodes.ASSET_UPDATE_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set the chosen thumbnail URL for an asset. Accepts URLs that are either
|
||
* (a) one of the asset's auto-generated candidates, or (b) a freshly uploaded
|
||
* URL on the org's media bucket. Anything else is rejected so callers can't
|
||
* point the asset at an arbitrary remote image.
|
||
*/
|
||
export async function selectAssetThumbnailService(orgId: string, assetId: string, thumbnailUrl: string) {
|
||
try {
|
||
const asset = await getAssetById(assetId, orgId);
|
||
assertAssetExists(asset);
|
||
|
||
const candidates = (asset?.thumbnailCandidates ?? []) as string[];
|
||
const mediaBase = getStorageConfig().mediaPublicBaseUrl?.replace(/\/$/, '') ?? null;
|
||
const isCandidate = candidates.includes(thumbnailUrl);
|
||
const isOnMediaBucket = Boolean(mediaBase && thumbnailUrl.startsWith(`${mediaBase}/`));
|
||
|
||
if (!isCandidate && !isOnMediaBucket) {
|
||
throw new AppError(
|
||
'Thumbnail URL must be a generated candidate or a media-bucket upload',
|
||
ErrorCodes.VALIDATION_ERROR,
|
||
400
|
||
);
|
||
}
|
||
|
||
const updated = await updateAsset(assetId, orgId, { thumbnailUrl });
|
||
return assertAssetExists(updated);
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
throw error;
|
||
}
|
||
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to update asset thumbnail',
|
||
ErrorCodes.ASSET_UPDATE_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function deleteAssetService(orgId: string, assetId: string) {
|
||
try {
|
||
await getAssetService(orgId, assetId);
|
||
|
||
const usageCount = await countAssetUsagesByAsset(assetId, orgId);
|
||
if (usageCount > 0) {
|
||
throw new AppError('Asset is still in use', ErrorCodes.ASSET_IN_USE, 409);
|
||
}
|
||
|
||
const deleted = await deleteAsset(assetId, orgId);
|
||
return assertAssetExists(deleted);
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
throw error;
|
||
}
|
||
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to delete asset',
|
||
ErrorCodes.ASSET_DELETE_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getAssetUsageGraphService(orgId: string, assetId: string) {
|
||
try {
|
||
await getAssetService(orgId, assetId);
|
||
const [usages, usageCount] = await Promise.all([
|
||
listAssetUsagesByAsset(assetId, orgId),
|
||
countAssetUsagesByAsset(assetId, orgId)
|
||
]);
|
||
|
||
return {
|
||
usageCount,
|
||
usages
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
throw error;
|
||
}
|
||
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to fetch asset usage',
|
||
ErrorCodes.ASSET_USAGE_FETCH_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function attachAssetService(orgId: string, assetId: string, profileId: string, input: TAssetAttach) {
|
||
try {
|
||
await getAssetService(orgId, assetId);
|
||
return await createAssetUsage({
|
||
organizationId: orgId,
|
||
assetId,
|
||
targetType: input.targetType,
|
||
targetId: input.targetId,
|
||
slotType: input.slotType,
|
||
slotKey: input.slotKey ?? null,
|
||
position: input.position ?? null,
|
||
createdByProfileId: profileId
|
||
});
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
throw error;
|
||
}
|
||
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to attach asset',
|
||
ErrorCodes.ASSET_ATTACH_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function detachAssetService(orgId: string, assetId: string, input: TAssetDetach) {
|
||
try {
|
||
await getAssetService(orgId, assetId);
|
||
const detached = await deleteAssetUsage(orgId, assetId, input);
|
||
if (!detached) {
|
||
throw new AppError('Asset usage not found', ErrorCodes.ASSET_USAGE_NOT_FOUND, 404);
|
||
}
|
||
|
||
return detached;
|
||
} catch (error) {
|
||
if (error instanceof AppError) {
|
||
throw error;
|
||
}
|
||
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to detach asset',
|
||
ErrorCodes.ASSET_DETACH_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function exportOrganizationAssetsService(orgId: string, query: TAssetExportQuery) {
|
||
try {
|
||
return await listAssetsForExport(orgId, query);
|
||
} catch (error) {
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to export assets',
|
||
ErrorCodes.ASSET_EXPORT_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getOrganizationAssetStorageService(orgId: string, query: TAssetStorageQuery) {
|
||
try {
|
||
return await getAssetStorageSummaryByOrg(orgId, query);
|
||
} catch (error) {
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to fetch asset storage summary',
|
||
ErrorCodes.ASSET_STORAGE_FETCH_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function getTranscriptForOrganizationAssetService(
|
||
orgId: string,
|
||
assetId: string
|
||
): Promise<TTranscriptResponse | null> {
|
||
try {
|
||
const asset = assertAssetExists(await getAssetById(assetId));
|
||
|
||
if (asset.organizationId !== orgId) {
|
||
throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
|
||
}
|
||
|
||
const row = await getMediaTranscriptByAsset(assetId, orgId);
|
||
if (!row) {
|
||
return null;
|
||
}
|
||
|
||
// Relative path served by `apps/api/src/routes/transcripts`. The
|
||
// dashboard resolves it into a full URL via the same Hono RPC base it
|
||
// uses for HLS, so `<track>` always loads same-origin behind the
|
||
// tenant-router Worker in prod or via CORS-allowed cross-origin in
|
||
// local dev. `vttUrlExpiresAt` is no longer meaningful — leaving it
|
||
// here so existing callers don't break, but it's a far-future stamp.
|
||
return toTranscriptResponse(assetId, row.language, row.segments, row.durationSeconds, row.updatedAt);
|
||
} catch (error) {
|
||
if (error instanceof AppError) throw error;
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to fetch transcript',
|
||
ErrorCodes.ASSET_FETCH_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build the transcript response. The VTT is rendered on the fly from these
|
||
* segments by `apps/api/src/routes/transcripts`, so the relative path carries
|
||
* a `?v=<updatedAt>` cache-buster to force the `<track>` to reload after an
|
||
* edit. `vttUrlExpiresAt` is no longer meaningful — kept so existing callers
|
||
* don't break.
|
||
*/
|
||
function toTranscriptResponse(
|
||
assetId: string,
|
||
language: string,
|
||
segments: TTranscriptResponse['segments'],
|
||
durationSeconds: number | null,
|
||
updatedAt: string
|
||
): TTranscriptResponse {
|
||
const version = Number.isNaN(Date.parse(updatedAt)) ? '' : `?v=${Date.parse(updatedAt)}`;
|
||
|
||
return {
|
||
language,
|
||
segments,
|
||
vttUrl: `/transcripts/${assetId}/track.vtt${version}`,
|
||
vttUrlExpiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||
durationSeconds: durationSeconds ?? null
|
||
};
|
||
}
|
||
|
||
export async function updateTranscriptForOrganizationAssetService(
|
||
orgId: string,
|
||
assetId: string,
|
||
segments: TUpdateTranscript['segments']
|
||
): Promise<TTranscriptResponse> {
|
||
try {
|
||
const asset = assertAssetExists(await getAssetById(assetId));
|
||
|
||
if (asset.organizationId !== orgId) {
|
||
throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
|
||
}
|
||
|
||
const text = segments
|
||
.map((segment) => segment.text.trim())
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
|
||
const row = await updateMediaTranscriptContent(assetId, orgId, { segments, text });
|
||
if (!row) {
|
||
throw new AppError('Transcript not found', ErrorCodes.ASSET_NOT_FOUND, 404);
|
||
}
|
||
|
||
return toTranscriptResponse(assetId, row.language, row.segments, row.durationSeconds, row.updatedAt);
|
||
} catch (error) {
|
||
if (error instanceof AppError) throw error;
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to update transcript',
|
||
ErrorCodes.ASSET_FETCH_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- HLS playback auth ---
|
||
|
||
export const HLS_COOKIE_NAME = 'cio_hls';
|
||
const HLS_COOKIE_TTL_SECONDS = 15 * 60;
|
||
|
||
function base64UrlEncode(input: ArrayBuffer | Uint8Array): string {
|
||
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
||
let binary = '';
|
||
for (const b of bytes) binary += String.fromCharCode(b);
|
||
|
||
return Buffer.from(binary, 'binary').toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
||
}
|
||
|
||
async function hmacSha256(secret: string, message: string): Promise<string> {
|
||
const { createHmac } = await import('crypto');
|
||
const sig = createHmac('sha256', secret).update(message).digest();
|
||
|
||
return base64UrlEncode(sig);
|
||
}
|
||
|
||
/**
|
||
* Low-level HMAC mint. Returns `<payload>.<signature>` where payload is
|
||
* base64url(`{aid, exp}`). No entitlement check — callers must verify
|
||
* access before calling this.
|
||
*/
|
||
export async function mintHlsToken(assetId: string): Promise<{
|
||
token: string;
|
||
expiresAt: string;
|
||
cookieName: string;
|
||
maxAgeSeconds: number;
|
||
}> {
|
||
const secret = process.env.HLS_SIGNING_SECRET;
|
||
if (!secret) {
|
||
throw new AppError('HLS signing not configured', ErrorCodes.INTERNAL_ERROR, 503);
|
||
}
|
||
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const exp = now + HLS_COOKIE_TTL_SECONDS;
|
||
const payload = base64UrlEncode(Buffer.from(JSON.stringify({ aid: assetId, exp })));
|
||
const signature = await hmacSha256(secret, payload);
|
||
|
||
return {
|
||
token: `${payload}.${signature}`,
|
||
expiresAt: new Date(exp * 1000).toISOString(),
|
||
cookieName: HLS_COOKIE_NAME,
|
||
maxAgeSeconds: HLS_COOKIE_TTL_SECONDS
|
||
};
|
||
}
|
||
|
||
export interface VerifiedHlsToken {
|
||
assetId: string;
|
||
expSeconds: number;
|
||
}
|
||
|
||
/**
|
||
* Verify a `cio_hls` token. Used by the API's HLS streaming route as an
|
||
* alternative to session auth (in local dev or wherever the request bypasses
|
||
* the tenant-router). Returns the decoded payload on success, null otherwise.
|
||
*/
|
||
export async function verifyHlsToken(token: string): Promise<VerifiedHlsToken | null> {
|
||
const secret = process.env.HLS_SIGNING_SECRET;
|
||
if (!secret) return null;
|
||
|
||
const dot = token.indexOf('.');
|
||
if (dot === -1) return null;
|
||
|
||
const payload = token.slice(0, dot);
|
||
const signature = token.slice(dot + 1);
|
||
|
||
const expected = await hmacSha256(secret, payload);
|
||
if (expected !== signature) return null;
|
||
|
||
try {
|
||
const decoded = JSON.parse(Buffer.from(payload.replaceAll('-', '+').replaceAll('_', '/'), 'base64').toString());
|
||
if (typeof decoded.aid !== 'string' || typeof decoded.exp !== 'number') return null;
|
||
if (decoded.exp <= Math.floor(Date.now() / 1000)) return null;
|
||
|
||
return { assetId: decoded.aid, expSeconds: decoded.exp };
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Mint a short-lived HMAC-signed cookie scoped to a single assetId. The
|
||
* tenant-router Worker (production) and the API's `/hls/*` streaming route
|
||
* (local dev / fallback) both verify the cookie before serving segments.
|
||
* Format: `<base64url(JSON({aid,exp}))>.<base64url(HMAC-SHA256(payload, secret))>`
|
||
*/
|
||
export async function issueHlsCookieService(orgId: string, assetId: string) {
|
||
try {
|
||
const asset = assertAssetExists(await getAssetById(assetId, orgId));
|
||
if (!asset.hlsManifestKey) {
|
||
throw new AppError('Asset has no HLS manifest', ErrorCodes.VALIDATION_ERROR, 400);
|
||
}
|
||
|
||
return await mintHlsToken(assetId);
|
||
} catch (error) {
|
||
if (error instanceof AppError) throw error;
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to issue HLS cookie',
|
||
ErrorCodes.INTERNAL_ERROR,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Public variant: mints the same HMAC cookie without org-membership.
|
||
* Caller must have already verified that the asset is reachable via the
|
||
* given public course/lesson path — typically by walking the public
|
||
* course tree and matching the lesson's video asset id.
|
||
*/
|
||
export async function issuePublicHlsCookie(assetId: string) {
|
||
const asset = await getAssetById(assetId);
|
||
if (!asset?.hlsManifestKey) {
|
||
throw new AppError('Asset has no HLS manifest', ErrorCodes.VALIDATION_ERROR, 400);
|
||
}
|
||
|
||
return await mintHlsToken(assetId);
|
||
}
|
||
|
||
// --- HLS upload lifecycle ---
|
||
|
||
async function deleteHlsAssetObjects(bucket: string, prefix: string): Promise<void> {
|
||
const { DeleteObjectsCommand, ListObjectsV2Command } = await import('@aws-sdk/client-s3');
|
||
const client = getS3Client();
|
||
|
||
let continuationToken: string | undefined;
|
||
do {
|
||
const list = await client.send(
|
||
new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, ContinuationToken: continuationToken })
|
||
);
|
||
const keys = (list.Contents ?? []).map((entry) => entry.Key).filter((k): k is string => Boolean(k));
|
||
if (keys.length) {
|
||
await client.send(
|
||
new DeleteObjectsCommand({
|
||
Bucket: bucket,
|
||
Delete: { Objects: keys.map((Key) => ({ Key })), Quiet: true }
|
||
})
|
||
);
|
||
}
|
||
continuationToken = list.IsTruncated ? list.NextContinuationToken : undefined;
|
||
} while (continuationToken);
|
||
}
|
||
|
||
/**
|
||
* Abort a `processing` HLS asset: clean up any partial R2 objects under
|
||
* `videos/{assetId}/` and delete the asset row. Idempotent — caller can
|
||
* fire this from the encoder's catch path without worrying about racing
|
||
* with successful finalize (we refuse to abort `active` assets).
|
||
*/
|
||
export async function abortHlsAssetService(orgId: string, assetId: string) {
|
||
try {
|
||
const asset = assertAssetExists(await getAssetById(assetId, orgId));
|
||
if (asset.status !== 'processing') {
|
||
throw new AppError('Asset is not in processing state', ErrorCodes.VALIDATION_ERROR, 409);
|
||
}
|
||
|
||
const config = getStorageConfig();
|
||
try {
|
||
await deleteHlsAssetObjects(config.bucketVideos, `${assetId}/`);
|
||
} catch (error) {
|
||
// Object cleanup is best-effort — log and continue so the orphaned
|
||
// DB row still gets removed. Stale R2 objects can be swept later.
|
||
console.error('abortHlsAssetService: failed to delete R2 objects', error);
|
||
}
|
||
|
||
const deleted = await deleteAsset(assetId, orgId);
|
||
return assertAssetExists(deleted);
|
||
} catch (error) {
|
||
if (error instanceof AppError) throw error;
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to abort HLS asset',
|
||
ErrorCodes.ASSET_DELETE_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
const HLS_CONTENT_TYPES: Record<string, string> = {
|
||
'.m3u8': 'application/vnd.apple.mpegurl',
|
||
'.ts': 'video/mp2t',
|
||
'.m4s': 'video/iso.segment',
|
||
'.mp4': 'video/mp4',
|
||
'.m4a': 'audio/mp4',
|
||
'.aac': 'audio/aac',
|
||
'.jpg': 'image/jpeg',
|
||
'.jpeg': 'image/jpeg',
|
||
'.png': 'image/png'
|
||
};
|
||
|
||
function contentTypeFor(path: string, overrides?: Record<string, string>): string {
|
||
const dotIndex = path.lastIndexOf('.');
|
||
if (dotIndex === -1) return 'application/octet-stream';
|
||
|
||
const ext = path.slice(dotIndex).toLowerCase();
|
||
return overrides?.[ext] ?? HLS_CONTENT_TYPES[ext] ?? 'application/octet-stream';
|
||
}
|
||
|
||
export async function initHlsAssetService(orgId: string, profileId: string, data: TInitHlsAsset) {
|
||
try {
|
||
const asset = await createHlsAssetPlaceholder({
|
||
organizationId: orgId,
|
||
createdByProfileId: profileId,
|
||
title: data.title ?? data.fileName,
|
||
byteSize: data.byteSize,
|
||
mimeType: data.mimeType
|
||
});
|
||
|
||
return {
|
||
assetId: asset.id,
|
||
keyPrefix: `${asset.id}/hls`
|
||
};
|
||
} catch (error) {
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to init HLS asset',
|
||
ErrorCodes.ASSET_CREATE_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns presigned PUT URLs for a list of relative paths under
|
||
* `videos/{assetId}/hls/`. Caller owns the layout — we only enforce that
|
||
* paths stay scoped to the asset's own prefix.
|
||
*/
|
||
export async function batchPresignHlsService(
|
||
orgId: string,
|
||
assetId: string,
|
||
data: TBatchPresignHls,
|
||
options?: { allowActive?: boolean }
|
||
) {
|
||
try {
|
||
const asset = assertAssetExists(await getAssetById(assetId, orgId));
|
||
if (asset.status !== 'processing' && !(options?.allowActive && asset.status === 'active')) {
|
||
throw new AppError('Asset is not in processing state', ErrorCodes.VALIDATION_ERROR, 409);
|
||
}
|
||
|
||
const config = getStorageConfig();
|
||
const prefix = `${assetId}/`;
|
||
|
||
for (const relativePath of data.paths) {
|
||
if (relativePath.includes('..') || relativePath.startsWith('/')) {
|
||
throw new AppError(`Invalid path: ${relativePath}`, ErrorCodes.VALIDATION_ERROR, 400);
|
||
}
|
||
}
|
||
|
||
// Sign all paths in parallel — sequential getSignedUrl calls for a
|
||
// 5-minute video produce ~225 segments × ~5ms each = >1s of needless
|
||
// serialised RTT before the encoder can start uploading anything.
|
||
const results = await Promise.all(
|
||
data.paths.map(async (relativePath) => {
|
||
const fileKey = `${prefix}${relativePath}`;
|
||
const contentType = contentTypeFor(relativePath, data.contentTypeByExtension);
|
||
const url = await generateUploadPresignedUrl(fileKey, config.bucketVideos, contentType);
|
||
return { relativePath, fileKey, url };
|
||
})
|
||
);
|
||
|
||
const urls: Record<string, string> = {};
|
||
const keysByPath: Record<string, string> = {};
|
||
for (const { relativePath, fileKey, url } of results) {
|
||
urls[relativePath] = url;
|
||
keysByPath[relativePath] = fileKey;
|
||
}
|
||
|
||
return { urls, keys: keysByPath };
|
||
} catch (error) {
|
||
if (error instanceof AppError) throw error;
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to presign HLS uploads',
|
||
ErrorCodes.ASSET_UPDATE_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function finalizeHlsAssetService(
|
||
orgId: string,
|
||
assetId: string,
|
||
profileId: string,
|
||
data: TFinalizeHlsAsset
|
||
) {
|
||
try {
|
||
const asset = assertAssetExists(await getAssetById(assetId, orgId));
|
||
if (asset.status !== 'processing') {
|
||
throw new AppError('Asset is not in processing state', ErrorCodes.VALIDATION_ERROR, 409);
|
||
}
|
||
|
||
const config = getStorageConfig();
|
||
const prefix = `${assetId}/`;
|
||
if (!data.manifestKey.startsWith(prefix)) {
|
||
throw new AppError('Manifest key must live under the asset prefix', ErrorCodes.VALIDATION_ERROR, 400);
|
||
}
|
||
|
||
if (data.audioKey && !data.audioKey.startsWith(prefix)) {
|
||
throw new AppError('Audio key must live under the asset prefix', ErrorCodes.VALIDATION_ERROR, 400);
|
||
}
|
||
|
||
// Thumbnails come pre-uploaded via /media/image, which returns absolute
|
||
// URLs. We persist them as-is rather than treating them as storage keys.
|
||
const thumbnailUrl = data.thumbnailUrl ?? asset.thumbnailUrl ?? null;
|
||
const thumbnailCandidates = data.thumbnailCandidateUrls?.length
|
||
? data.thumbnailCandidateUrls
|
||
: (asset.thumbnailCandidates ?? []);
|
||
|
||
const hls1080Status: THls1080Status = data.hls1080Status ?? 'none';
|
||
const metadata = {
|
||
...(typeof asset.metadata === 'object' && asset.metadata !== null && !Array.isArray(asset.metadata)
|
||
? asset.metadata
|
||
: {}),
|
||
videoCodec: data.videoCodec ?? null,
|
||
audioCodec: data.audioCodec ?? null,
|
||
format: 'hls',
|
||
...(data.sourceWidth != null ? { sourceWidth: data.sourceWidth } : {}),
|
||
...(data.sourceHeight != null ? { sourceHeight: data.sourceHeight } : {}),
|
||
...(data.hlsRenditions?.length ? { hlsRenditions: data.hlsRenditions } : {}),
|
||
hls1080Status
|
||
};
|
||
|
||
const updated = await finalizeHlsAssetQuery(assetId, orgId, {
|
||
manifestKey: data.manifestKey,
|
||
audioKey: data.audioKey ?? null,
|
||
durationSeconds: data.durationSeconds != null ? Math.round(data.durationSeconds) : null,
|
||
aspectRatio: data.aspectRatio ?? null,
|
||
byteSize: data.byteSize,
|
||
thumbnailUrl,
|
||
thumbnailCandidates,
|
||
metadata
|
||
});
|
||
|
||
const finalized = assertAssetExists(updated);
|
||
|
||
if (data.audioKey && process.env.OPENAI_API_KEY) {
|
||
void enqueueAudioTranscriptionForAsset({
|
||
organizationId: orgId,
|
||
assetId,
|
||
audioKey: data.audioKey,
|
||
triggeredByProfileId: profileId
|
||
});
|
||
}
|
||
|
||
return finalized;
|
||
} catch (error) {
|
||
if (error instanceof AppError) throw error;
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to finalize HLS asset',
|
||
ErrorCodes.ASSET_UPDATE_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
type HlsAssetMetadata = {
|
||
sourceWidth?: number;
|
||
sourceHeight?: number;
|
||
hlsRenditions?: string[];
|
||
hls1080Status?: THls1080Status;
|
||
format?: string;
|
||
};
|
||
|
||
function readHlsAssetMetadata(asset: { metadata: unknown }): HlsAssetMetadata {
|
||
if (typeof asset.metadata !== 'object' || asset.metadata === null || Array.isArray(asset.metadata)) {
|
||
return {};
|
||
}
|
||
|
||
return asset.metadata as HlsAssetMetadata;
|
||
}
|
||
|
||
function isAllowedHls1080Path(relativePath: string): boolean {
|
||
if (relativePath === 'master.m3u8') return true;
|
||
if (!relativePath.startsWith('p1080/')) return false;
|
||
if (relativePath.includes('..') || relativePath.startsWith('/')) return false;
|
||
|
||
return true;
|
||
}
|
||
|
||
function assertHls1080Eligible(
|
||
asset: Awaited<ReturnType<typeof getAssetById>>,
|
||
options?: { probeSourceHeight?: number }
|
||
) {
|
||
const resolved = assertAssetExists(asset);
|
||
if (!resolved.hlsManifestKey) {
|
||
throw new AppError('Asset has no HLS manifest', ErrorCodes.VALIDATION_ERROR, 400);
|
||
}
|
||
|
||
if (resolved.status !== 'active') {
|
||
throw new AppError('Asset must be active', ErrorCodes.VALIDATION_ERROR, 409);
|
||
}
|
||
|
||
const metadata = readHlsAssetMetadata(resolved);
|
||
const renditions = metadata.hlsRenditions ?? [];
|
||
if (renditions.includes('p1080')) {
|
||
throw new AppError('1080p rendition already exists', ErrorCodes.VALIDATION_ERROR, 409);
|
||
}
|
||
|
||
const storedSourceHeight = metadata.sourceHeight;
|
||
const effectiveSourceHeight = options?.probeSourceHeight ?? storedSourceHeight;
|
||
if (effectiveSourceHeight == null) {
|
||
return { asset: resolved, metadata, sourceHeightKnown: false as const };
|
||
}
|
||
|
||
if (effectiveSourceHeight < 1080) {
|
||
throw new AppError('Source video does not support 1080p', ErrorCodes.VALIDATION_ERROR, 400);
|
||
}
|
||
|
||
return { asset: resolved, metadata, sourceHeightKnown: true as const };
|
||
}
|
||
|
||
/**
|
||
* Presign PUT URLs for a manual p1080 rendition on an active HLS asset.
|
||
* Only paths under `p1080/` and the root `master.m3u8` are accepted.
|
||
*/
|
||
export async function batchPresignHls1080Service(orgId: string, assetId: string, data: TBatchPresignHls1080) {
|
||
try {
|
||
const asset = await getAssetById(assetId, orgId);
|
||
assertHls1080Eligible(asset);
|
||
|
||
for (const relativePath of data.paths) {
|
||
if (!isAllowedHls1080Path(relativePath)) {
|
||
throw new AppError(`Invalid 1080p path: ${relativePath}`, ErrorCodes.VALIDATION_ERROR, 400);
|
||
}
|
||
}
|
||
|
||
return await batchPresignHlsService(orgId, assetId, data, { allowActive: true });
|
||
} catch (error) {
|
||
if (error instanceof AppError) throw error;
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to presign 1080p HLS uploads',
|
||
ErrorCodes.ASSET_UPDATE_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
async function readHlsManifestBody(bucket: string, key: string): Promise<string> {
|
||
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
||
const client = getS3Client();
|
||
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
||
const body = await response.Body?.transformToString('utf-8');
|
||
if (!body) {
|
||
throw new AppError('HLS manifest is empty', ErrorCodes.VALIDATION_ERROR, 400);
|
||
}
|
||
|
||
return body;
|
||
}
|
||
|
||
/**
|
||
* Finalize a browser-generated p1080 rendition: verify the master manifest
|
||
* references `p1080/playlist.m3u8`, then persist rendition metadata.
|
||
*/
|
||
export async function finalizeHls1080Service(orgId: string, assetId: string, data: TFinalizeHls1080) {
|
||
try {
|
||
const asset = await getAssetById(assetId, orgId);
|
||
const eligibility = assertHls1080Eligible(asset, { probeSourceHeight: data.sourceHeight });
|
||
const { asset: resolved, metadata } = eligibility;
|
||
|
||
if (!eligibility.sourceHeightKnown) {
|
||
if (data.sourceHeight == null) {
|
||
throw new AppError(
|
||
'Source resolution is unknown for this asset — select the original video file to verify 1080p support',
|
||
ErrorCodes.VALIDATION_ERROR,
|
||
400
|
||
);
|
||
}
|
||
|
||
if (data.sourceHeight < 1080) {
|
||
throw new AppError('Source video does not support 1080p', ErrorCodes.VALIDATION_ERROR, 400);
|
||
}
|
||
}
|
||
|
||
const config = getStorageConfig();
|
||
const manifestKey = resolved.hlsManifestKey!;
|
||
const manifestBody = await readHlsManifestBody(config.bucketVideos, manifestKey);
|
||
if (!manifestBody.includes('p1080/playlist.m3u8')) {
|
||
throw new AppError('Master manifest does not include 1080p rendition', ErrorCodes.VALIDATION_ERROR, 400);
|
||
}
|
||
|
||
const renditions = [...(metadata.hlsRenditions ?? [])];
|
||
if (!renditions.includes('p1080')) {
|
||
renditions.push('p1080');
|
||
}
|
||
|
||
const mergedMetadata = {
|
||
...(typeof resolved.metadata === 'object' && resolved.metadata !== null && !Array.isArray(resolved.metadata)
|
||
? resolved.metadata
|
||
: {}),
|
||
...(metadata.sourceWidth == null && data.sourceWidth != null ? { sourceWidth: data.sourceWidth } : {}),
|
||
...(metadata.sourceHeight == null && data.sourceHeight != null ? { sourceHeight: data.sourceHeight } : {}),
|
||
hlsRenditions: renditions,
|
||
hls1080Status: 'ready' as const
|
||
};
|
||
|
||
const updated = await finalizeHls1080Rendition(assetId, orgId, {
|
||
byteSize: (resolved.byteSize ?? 0) + data.additionalByteSize,
|
||
metadata: mergedMetadata
|
||
});
|
||
|
||
return assertAssetExists(updated);
|
||
} catch (error) {
|
||
if (error instanceof AppError) throw error;
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to finalize 1080p HLS rendition',
|
||
ErrorCodes.ASSET_UPDATE_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* HLS assets ship audio as a separate `audio.m4a` already extracted by
|
||
* Mediabunny in the browser. The existing transcription pipeline accepts
|
||
* any media key in the videos bucket — its extract-audio leg is a no-op
|
||
* on input that's already audio.
|
||
*/
|
||
async function enqueueAudioTranscriptionForAsset(input: {
|
||
organizationId: string;
|
||
assetId: string;
|
||
audioKey: string;
|
||
triggeredByProfileId: string;
|
||
}): Promise<void> {
|
||
try {
|
||
const { startTranscriptionOnlyMediaJob } = await import('../jobs/media-jobs');
|
||
await startTranscriptionOnlyMediaJob({
|
||
organizationId: input.organizationId,
|
||
assetId: input.assetId,
|
||
triggeredByProfileId: input.triggeredByProfileId
|
||
});
|
||
} catch (error) {
|
||
console.error('enqueueAudioTranscriptionForAsset failed:', error);
|
||
}
|
||
}
|
||
|
||
export async function getYouTubeMetadataService(_orgId: string, query: TYouTubeMetadataQuery) {
|
||
const videoId = extractYouTubeVideoId(query.url);
|
||
if (!videoId) {
|
||
throw new AppError('Invalid YouTube URL', ErrorCodes.VALIDATION_ERROR, 400, 'url');
|
||
}
|
||
|
||
const sourceUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||
|
||
try {
|
||
const [oembed, watchPageMetadata] = await Promise.all([
|
||
fetchYouTubeOEmbed(sourceUrl),
|
||
fetchYouTubeWatchPageMetadata(videoId)
|
||
]);
|
||
|
||
return {
|
||
videoId,
|
||
sourceUrl,
|
||
title: oembed.title ?? watchPageMetadata.title ?? 'YouTube video',
|
||
durationSeconds: watchPageMetadata.durationSeconds,
|
||
thumbnailUrl:
|
||
oembed.thumbnailUrl ?? watchPageMetadata.thumbnailUrl ?? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
|
||
};
|
||
} catch (error) {
|
||
throw new AppError(
|
||
error instanceof Error ? error.message : 'Failed to fetch YouTube metadata',
|
||
ErrorCodes.ASSET_FETCH_FAILED,
|
||
500
|
||
);
|
||
}
|
||
}
|