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(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 { 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, / { 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 { 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 `` 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=` cache-buster to force the `` 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 { 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 { const { createHmac } = await import('crypto'); const sig = createHmac('sha256', secret).update(message).digest(); return base64UrlEncode(sig); } /** * Low-level HMAC mint. Returns `.` 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 { 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: `.` */ 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 { 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 = { '.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 { 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 = {}; const keysByPath: Record = {}; 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>, 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 { 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 { 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 ); } }