import { request } from "undici"; import ffmpeg from "ffmpeg-static"; import { spawn } from "child_process"; import { create as contentDisposition } from "content-disposition-header"; import { env } from "../config.js"; import { destroyInternalStream } from "./manage.js"; import { hlsExceptions } from "../processing/service-config.js"; import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js"; const ffmpegArgs = { webm: ["-c:v", "copy", "-c:a", "copy"], mp4: ["-c:v", "copy", "-c:a", "copy", "-movflags", "faststart+frag_keyframe+empty_moov"], m4a: ["-movflags", "frag_keyframe+empty_moov"], gif: ["-vf", "scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"] } const metadataTags = [ "album", "copyright", "title", "artist", "track", "date", ]; const convertMetadataToFFmpeg = (metadata) => { let args = []; for (const [ name, value ] of Object.entries(metadata)) { if (metadataTags.includes(name)) { args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`); } else { throw `${name} metadata tag is not supported.`; } } return args; } const toRawHeaders = (headers) => { return Object.entries(headers) .map(([key, value]) => `${key}: ${value}\r\n`) .join(''); } const killProcess = (p) => { p?.kill('SIGTERM'); // ask the process to terminate itself gracefully setTimeout(() => { if (p?.exitCode === null) p?.kill('SIGKILL'); // brutally murder the process if it didn't quit }, 5000); } const getCommand = (args) => { if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) { return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]] } return [ffmpeg, args] } const proxy = async (streamInfo, res) => { const abortController = new AbortController(); const shutdown = () => ( closeRequest(abortController), closeResponse(res), destroyInternalStream(streamInfo.urls) ); try { res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.setHeader('Content-disposition', contentDisposition(streamInfo.filename)); const { body: stream, headers, statusCode } = await request(streamInfo.urls, { headers: { ...getHeaders(streamInfo.service), Range: streamInfo.range }, signal: abortController.signal, maxRedirections: 16 }); res.status(statusCode); for (const headerName of ['accept-ranges', 'content-type', 'content-length']) { if (headers[headerName]) { res.setHeader(headerName, headers[headerName]); } } pipe(stream, res, shutdown); } catch { shutdown(); } } const merge = (streamInfo, res) => { let process; const shutdown = () => ( killProcess(process), closeResponse(res), streamInfo.urls.map(destroyInternalStream) ); const headers = getHeaders(streamInfo.service); const rawHeaders = toRawHeaders(headers); try { if (streamInfo.urls.length !== 2) return shutdown(); const format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1]; let args = [ '-loglevel', '-8', '-headers', rawHeaders, '-i', streamInfo.urls[0], '-headers', rawHeaders, '-i', streamInfo.urls[1], '-map', '0:v', '-map', '1:a', ] args = args.concat(ffmpegArgs[format]); if (hlsExceptions.includes(streamInfo.service) && streamInfo.isHLS) { if (streamInfo.service === "youtube" && format === "webm") { args.push('-c:a', 'libopus'); } else { args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc'); } } if (streamInfo.metadata) { args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata)) } args.push('-f', format, 'pipe:3'); process = spawn(...getCommand(args), { windowsHide: true, stdio: [ 'inherit', 'inherit', 'inherit', 'pipe' ], }); const [,,, muxOutput] = process.stdio; res.setHeader('Connection', 'keep-alive'); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); pipe(muxOutput, res, shutdown); process.on('close', shutdown); res.on('finish', shutdown); } catch { shutdown(); } } const remux = (streamInfo, res) => { let process; const shutdown = () => ( killProcess(process), closeResponse(res), destroyInternalStream(streamInfo.urls) ); try { let args = [ '-loglevel', '-8', '-headers', toRawHeaders(getHeaders(streamInfo.service)), ] if (streamInfo.service === "twitter") { args.push('-seekable', '0') } args.push( '-i', streamInfo.urls, '-c:v', 'copy', ) if (streamInfo.type === "mute") { args.push('-an'); } if (hlsExceptions.includes(streamInfo.service)) { if (streamInfo.type !== "mute") { args.push('-c:a', 'aac') } args.push('-bsf:a', 'aac_adtstoasc'); } let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1]; if (format === "mp4") { args.push('-movflags', 'faststart+frag_keyframe+empty_moov') } args.push('-f', format, 'pipe:3'); process = spawn(...getCommand(args), { windowsHide: true, stdio: [ 'inherit', 'inherit', 'inherit', 'pipe' ], }); const [,,, muxOutput] = process.stdio; res.setHeader('Connection', 'keep-alive'); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); pipe(muxOutput, res, shutdown); process.on('close', shutdown); res.on('finish', shutdown); } catch { shutdown(); } } const convertAudio = (streamInfo, res) => { let process; const shutdown = () => ( killProcess(process), closeResponse(res), destroyInternalStream(streamInfo.urls) ); try { let args = [ '-loglevel', '-8', '-headers', toRawHeaders(getHeaders(streamInfo.service)), ] if (streamInfo.service === "twitter") { args.push('-seekable', '0'); } args.push( '-i', streamInfo.urls, '-vn' ) if (streamInfo.audioCopy) { args.push("-c:a", "copy") } else { args.push("-b:a", `${streamInfo.audioBitrate}k`) } if (streamInfo.audioFormat === "mp3" && streamInfo.audioBitrate === "8") { args.push("-ar", "12000"); } if (streamInfo.audioFormat === "opus") { args.push("-vbr", "off") } if (ffmpegArgs[streamInfo.audioFormat]) { args = args.concat(ffmpegArgs[streamInfo.audioFormat]) } if (streamInfo.metadata) { args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata)) } args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3'); process = spawn(...getCommand(args), { windowsHide: true, stdio: [ 'inherit', 'inherit', 'inherit', 'pipe' ], }); const [,,, muxOutput] = process.stdio; res.setHeader('Connection', 'keep-alive'); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); pipe(muxOutput, res, shutdown); res.on('finish', shutdown); } catch { shutdown(); } } const convertGif = (streamInfo, res) => { let process; const shutdown = () => (killProcess(process), closeResponse(res)); try { let args = [ '-loglevel', '-8' ] if (streamInfo.service === "twitter") { args.push('-seekable', '0') } args.push('-i', streamInfo.urls); args = args.concat(ffmpegArgs.gif); args.push('-f', "gif", 'pipe:3'); process = spawn(...getCommand(args), { windowsHide: true, stdio: [ 'inherit', 'inherit', 'inherit', 'pipe' ], }); const [,,, muxOutput] = process.stdio; res.setHeader('Connection', 'keep-alive'); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); pipe(muxOutput, res, shutdown); process.on('close', shutdown); res.on('finish', shutdown); } catch { shutdown(); } } export default { proxy, merge, remux, convertAudio, convertGif, }