import { FastifyInstance, FastifyRequest } from 'fastify' import { PrismaClient } from '../../generated/prisma' import { withAccelerate } from "@prisma/extension-accelerate" import { getOrDownloadAsset } from '../utils/cache' import { getS3Client } from '../utils/s3' import { env } from '../config/env' import { signUrl } from '../utils/cdn' import { constants } from '../config/constants' import { readFile } from 'fs/promises' import { extractBasePath } from '../utils/filesystem' import { dirname } from 'node:path' import logger from '../utils/logger' const prisma = new PrismaClient().$extends(withAccelerate()) const s3 = getS3Client() // function isMasterPlaylist(content: string): boolean { // return content // .split('\n') // .some(line => !line.startsWith('#') && line.trim().endsWith('.m3u8')) // } // function extractTokenParamsFromSignedUrl(signedUrl: string): URLSearchParams { // const match = signedUrl.match(/\/bcdn_token=[^/]+\/|\/bcdn_token=[^&]+/) // if (!match) return new URLSearchParams() // const queryString = match[0].replace(/^\//, '').replace(/\/$/, '') // return new URLSearchParams(queryString) // } // function patchMediaUrlsInPlaylist( // playlist: string, // options: { cdnPrefix?: string; queryParams?: URLSearchParams } // ): string { // const lines = playlist.split('\n') // const query = options.queryParams?.toString() // const cdnPrefix = options.cdnPrefix?.replace(/^https?:\/\/[^/]+/, '').replace(/^\/|\/$/g, '') // return lines.map(line => { // if (line.startsWith('#') || line.trim() === '') return line // if (line.startsWith('http://') || line.startsWith('https://')) { // try { // const url = new URL(line) // if (url.search) return line // return `${url.origin}${query ? '/' + query : ''}${url.pathname}` // } catch { // return line // Not a URL — fallback // } // } // // Relative segment (e.g. `stream_1.ts`) // let patched = line // if (cdnPrefix) patched = `${env.CDN_ORIGIN}/${cdnPrefix}/${patched}` // if (query) patched += `?${query}` // return patched // }).join('\n') // } function rewriteMp4ReferencesWithSignedUrls( playlistContent: string, cdnBasePath: string, signFn: (path: string) => string ): string { logger.debug(`rewriteMp4ReferencesWithSignedUrls called with ${playlistContent} ${cdnBasePath} ${signFn}`) const cleanBase = cdnBasePath.replace(/^\/|\/$/g, '') // remove leading/trailing slash return playlistContent .split('\n') .map((line) => { const trimmed = line.trim(); // Bare MP4 reference (e.g. "source.mp4") if (/^[^\/?#]+\.(mp4)$/.test(trimmed)) { const path = `${cleanBase}/${trimmed}`; const signedUrl = signFn(path); return signedUrl; } // URI="source.mp4" return line.replace(/URI="([^"]+\.mp4)"/g, (_, file) => { const path = `${cleanBase}/${file.replace(/^\/+/, '')}`; const signedUrl = signFn(path); return `URI="${signedUrl}"`; }); }) .join('\n'); } export default async function registerHlsRoute(app: FastifyInstance) { app.get('/hls/:vodId/:manifest', async (request, reply) => { const { vodId, manifest } = request.params as { vodId: string manifest: string } const vod = await prisma.vod.findFirstOrThrow({ where: { id: vodId }, select: { hlsPlaylist: true }, }) logger.debug(`vod as follows`) logger.debug(vod) if (!vod.hlsPlaylist) return reply.status(404).send('') const s3Key = `${dirname(vod.hlsPlaylist)}/${manifest}` const filePath = await getOrDownloadAsset(s3, process.env.S3_BUCKET!, s3Key) const playlistContent = await readFile(filePath, 'utf8') // If it's the master manifest, just return it as-is if (manifest === 'master.m3u8') { return reply .type('application/x-mpegURL') .header('Cache-Control', 'public, max-age=60') .send(playlistContent) } // Otherwise, rewrite .mp4 references with signed URLs const tokenPath = `/${dirname(vod.hlsPlaylist)}/` logger.debug(`tokenPath=${tokenPath} hlsPlaylist=${vod.hlsPlaylist}`) if (!tokenPath.startsWith('/')) { throw new Error('tokenPath did not start with a forward slash'); } if (!tokenPath.endsWith('/')) { throw new Error('tokenPath did not end with a forward slash'); } if (tokenPath.endsWith('//')) { throw new Error('tokenPath ended with a double forward slash'); } if (tokenPath.startsWith('//')) { throw new Error('tokenPath started with a double forward slash'); } const signedPlaylist = rewriteMp4ReferencesWithSignedUrls( playlistContent, tokenPath, (path) => signUrl(`${env.CDN_ORIGIN}/${path}`, { securityKey: env.CDN_TOKEN_SECRET!, expirationTime: constants.timeUnits.oneDayInSeconds, isDirectory: true, pathAllowed: tokenPath, }) ) return reply .type('application/x-mpegURL') .header('Cache-Control', 'public, max-age=60') .send(signedPlaylist) }) }