160 lines
5.0 KiB
TypeScript
160 lines
5.0 KiB
TypeScript
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)
|
|
})
|
|
}
|
|
|