| import ffmpeg from "ffmpeg-static"; |
| import ffprobe from "ffprobe-static"; |
| import fluent_ffmpeg, { type FfprobeData } from "fluent-ffmpeg"; |
| import createError from "http-errors"; |
| import { Context } from "elysia"; |
| import { T_Probe } from "./types"; |
| import { NS_misc } from "./misc"; |
| import { NS_promise } from "./promise"; |
| import { NS_error } from "./error"; |
| import UserAgent from "user-agents"; |
| import { exec, execSync } from "node:child_process"; |
|
|
| const APP_ENV = process.env.APP_ENV; |
| const FFMPEG_V = process.env.FFMPEG_V; |
| const FFPROBE_V = process.env.FFPROBE_V; |
| const CPU_0V1 = process.env.CPU_0V1; |
|
|
| const is_prod = |
| process.env.NODE_ENV === "production" || |
| APP_ENV === "prod" || |
| APP_ENV === "pre_prod"; |
|
|
| const ffmpeg_path = is_prod ? "ffmpeg" : ffmpeg || "ffmpeg"; |
| const ffprobe_path = is_prod ? "ffprobe" : ffprobe.path || "ffprobe"; |
|
|
| |
| |
| |
| |
|
|
| fluent_ffmpeg.setFfmpegPath(ffmpeg_path); |
| fluent_ffmpeg.setFfprobePath(ffprobe_path); |
|
|
| let hw_encoder: any = await get_hardware_encoder(); |
| hw_encoder = |
| typeof hw_encoder !== "string" || !hw_encoder.trim() |
| ? "libx264" |
| : hw_encoder.trim(); |
| |
|
|
| console.log(`🚀 Using hardware encoder: ${hw_encoder}`); |
|
|
| const TIMEOUT_MS = 20_000; |
| const PROBE_TIMEOUT_MS = 30_000; |
|
|
| function getFfmpegEncoderConfig() { |
| try { |
| |
| execSync(`${ffmpeg_path} -y -f lavfi -i testsrc=duration=0.5 -c:v h264_qsv -f null -`, { |
| stdio: 'ignore', |
| timeout: 2000 |
| }); |
| |
| |
| return { |
| videoCodec: 'h264_qsv', |
| preset: '7', |
| extraFlags: [] |
| }; |
| } catch (error) { |
| console.error({error}); |
| |
| return { |
| videoCodec: 'libx264', |
| preset: 'ultrafast', |
| extraFlags: ['-crf', '23'] |
| }; |
| } |
| } |
|
|
| function get_hardware_encoder() { |
| return new Promise((resolve) => { |
| |
| const checkCmd = `"${ffmpeg_path}" -encoders | grep -E "nvenc|qsv|vaapi"`; |
|
|
| exec(checkCmd, (error, stdout) => { |
| if (error || !stdout) { |
| return resolve(null); |
| } |
|
|
| const output = stdout.toLowerCase(); |
| if (output.includes("nvenc")) return resolve("h264_nvenc"); |
| if (output.includes("qsv")) return resolve("h264_qsv"); |
| if (output.includes("vaapi")) return resolve("h264_vaapi"); |
|
|
| resolve(null); |
| }); |
| }); |
| } |
|
|
| async function F_probe_v1(arg0: { |
| playback_headers?: Record<string, any> | null; |
| playback_url: URL; |
| context?: Context; |
| }) { |
| const { playback_headers, playback_url, context } = arg0; |
|
|
| const [E_, R_] = await NS_promise.F_catch_promise({ |
| promise: NS_misc.F_test_connection({ |
| url: playback_url, |
| headers: playback_headers |
| }) |
| }); |
|
|
| if (E_) { |
| const { status, message } = NS_error.F_collect_error_status_n_message({ |
| ERR: E_ |
| }); |
|
|
| if (["522", "510", "502", "503", "404", "410"].includes(`${status}`)) |
| throw E_; |
| } |
|
|
| const command = fluent_ffmpeg(); |
|
|
| |
| command.addOption("-timeout", (30 * 1_000_000).toString()); |
| command.addOption("-reconnect", "1"); |
| command.addOption("-reconnect_streamed", "1"); |
| command.addOption("-reconnect_delay_max", "5"); |
| command.addOption("-multiple_requests", "1"); |
| |
|
|
| if (is_prod && FFPROBE_V === "latest") { |
| command.addOption("-allowed_segment_extensions", "ALL"); |
| command.addOption("-allowed_extensions", "ALL"); |
| command.addOption("-hls_flags", "+nocheck_content_type"); |
| command.addOption("-extension_picky", "0"); |
| command.addOption("-protocol_whitelist", "file,http,https,tcp,tls,crypto"); |
| command.addOption("-strict", "experimental"); |
| } |
|
|
| command.input(playback_url.href); |
|
|
| if (playback_headers && Object.keys(playback_headers).length > 0) { |
| const header_string = Object.entries(playback_headers) |
| .map(([k, v]) => `${k}: ${v}`) |
| .join("\r\n"); |
|
|
| command.addOption("-headers", header_string); |
| } |
|
|
| command.addOption("-v", "error"); |
| command.addOption("-probesize", "5000000"); |
| command.addOption("-analyzeduration", "5000000"); |
| command.addOption( |
| "-show_entries", |
| "format=duration,size,format_name:stream=index,codec_type,codec_name,height,width,bit_rate" |
| ); |
| command.addOption("-show_entries", "stream_tags=language"); |
| command.addOption("-of", "json"); |
|
|
| const metadata = await new Promise<FfprobeData>((resolve, reject) => { |
| command.ffprobe((err, data) => { |
| if (err) { |
| console.error("[PROBE ERROR]:", err.message); |
|
|
| |
| if (err.message.includes("resolve hostname")) { |
| return reject( |
| createError( |
| 502, |
| `Cannot resolve hostname: ${playback_url.hostname}. Check network/DNS.` |
| ) |
| ); |
| } |
| if (err.message.includes("I/O error")) { |
| return reject( |
| createError( |
| 502, |
| `Network I/O error: Cannot reach ${playback_url.href}` |
| ) |
| ); |
| } |
|
|
| return reject(createError(500, `Probe failed: ${err.message}`)); |
| } |
| resolve(data); |
| }); |
| }); |
|
|
| const streams = metadata.streams; |
|
|
| let hasSubs = false; |
|
|
| const audioStreams: any[] = []; |
| const videoStreams: any[] = []; |
| const subStreams: any[] = []; |
|
|
| |
| for (const stream of streams) { |
| if (stream.codec_type === "video") { |
| |
| videoStreams.push(stream); |
| } else if (stream.codec_type === "audio") { |
| |
| audioStreams.push(stream); |
| } else if (stream.codec_type === "subtitle") { |
| hasSubs = true; |
| subStreams.push(stream); |
| } |
| } |
|
|
| const videoStream = streams.find((s) => s.codec_type === "video"); |
| const audioStream = streams.find((s) => s.codec_type === "audio"); |
|
|
| |
|
|
| |
| const englishAudio = audioStreams.find((s) => s.tags?.language === "eng"); |
| const audioIndex = englishAudio?.index ?? audioStreams[0]?.index ?? 1; |
|
|
| |
| const englishSub = subStreams.find((s) => s.tags?.language === "eng"); |
| const subIndex = englishSub?.index ?? subStreams[0]?.index ?? null; |
|
|
| |
| const target720 = videoStreams.find((s) => s.height === 720); |
| const bestVideo = |
| target720 || |
| videoStreams.reduce( |
| (best, current) => |
| (current.height || 0) > (best.height || 0) ? current : best, |
| videoStreams[0] |
| ); |
|
|
| const bestVideoIndex = bestVideo?.index; |
|
|
| const probe_data: T_Probe = { |
| videoCodec: videoStream?.codec_name || "none", |
| audioCodec: audioStream?.codec_name || "none", |
| hasSubs, |
| duration: metadata.format.duration, |
| size: metadata.format.size, |
| format_name: metadata.format.format_name, |
| audioIndex, |
| subIndex, |
| bestVideoIndex |
| }; |
|
|
| return { |
| probe_data, |
| metadata |
| }; |
| } |
|
|
| async function F_probe_v2(arg0: { |
| playback_headers?: Record<string, any> | null; |
| playback_url: URL; |
| playback_type?: string; |
| context?: Context; |
| _log?: boolean; |
| file_mode?: { |
| path: string; |
| }; |
| }) { |
| const { playback_url, context, file_mode } = arg0; |
| let { playback_headers, _log } = arg0; |
|
|
| if (typeof _log !== "boolean") { |
| _log = true; |
| } |
|
|
| if (!playback_headers) { |
| playback_headers = {}; |
| } |
|
|
| if (!playback_url && !file_mode?.path) { |
| throw Error("Invalid playback endpoint"); |
| } |
|
|
| if (playback_url && !file_mode?.path) { |
| const [E_, R_] = await NS_promise.F_catch_promise({ |
| promise: NS_misc.F_test_connection({ |
| url: playback_url, |
| headers: playback_headers |
| }) |
| }); |
|
|
| if (E_) { |
| const { status, message } = NS_error.F_collect_error_status_n_message({ |
| ERR: E_ |
| }); |
|
|
| if ( |
| ["522", "510", "502", "503", "404", "410", "429"].includes(`${status}`) |
| ) |
| throw E_; |
| } |
| } |
|
|
| const controller = new AbortController(); |
|
|
| function kill_controller() { |
| controller.abort(); |
| console.log("[CONTROLLER] Request aborted"); |
| } |
|
|
| const t = setTimeout(() => kill_controller(), PROBE_TIMEOUT_MS); |
|
|
| const abort_signal = context?.request.signal; |
|
|
| |
| if (abort_signal) { |
| if (abort_signal.aborted) { |
| kill_controller(); |
| } else { |
| abort_signal.addEventListener( |
| "abort", |
| () => { |
| kill_controller(); |
| }, |
| { |
| once: true |
| } |
| ); |
| } |
| } |
|
|
| console.log("\n", "PING PROBE NOW", "\n"); |
|
|
| const command = [ffprobe_path, "-v", "error"]; |
|
|
| command.push("-probesize", "5000000"); |
| command.push("-analyzeduration", "5000000"); |
| command.push( |
| "-show_entries", |
| "format=duration,size,format_name:stream=index,codec_type,codec_name,height,width,bit_rate" |
| ); |
| command.push("-show_entries", "stream_tags=language"); |
| command.push("-of", "json"); |
|
|
| if (is_prod && FFPROBE_V === "latest") { |
| command.push("-allowed_segment_extensions", "ALL"); |
| command.push("-allowed_extensions", "ALL"); |
| command.push("-hls_flags", "+nocheck_content_type"); |
| command.push("-extension_picky", "0"); |
| command.push("-protocol_whitelist", "file,http,https,tcp,tls,crypto"); |
| command.push("-strict", "experimental"); |
| } |
|
|
| |
| function add_headers_if_needed() { |
| |
| const ua = NS_misc.F_get_obj_kv({ |
| name: "User-Agent", |
| obj: playback_headers |
| }); |
|
|
| if (!ua) { |
| try { |
| |
| const userAgent = |
| UserAgent.random({ deviceCategory: "desktop" }) || new UserAgent(); |
|
|
| if (!playback_headers) { |
| playback_headers = {}; |
| } |
|
|
| playback_headers["User-Agent"] = userAgent.toString(); |
| } catch (err: any) {} |
| } |
|
|
| if (playback_headers && Object.keys(playback_headers).length > 0) { |
| const ua = NS_misc.F_get_obj_kv({ |
| name: "User-Agent", |
| obj: playback_headers |
| }); |
| const ref = NS_misc.F_get_obj_kv({ |
| name: "Referer", |
| obj: playback_headers |
| }); |
|
|
| |
| |
| |
|
|
| |
| const other_headers = Object.entries(playback_headers) |
| .filter(([k]) => !["user-agentx", "refererx"].includes(k.toLowerCase())) |
| .map(([k, v]) => `${k}: ${v}`) |
| |
| .join("\r\n"); |
|
|
| if (other_headers) { |
| command.push("-headers", other_headers + "\r\n"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| } |
| } |
|
|
| if (!file_mode) add_headers_if_needed(); |
|
|
| |
| command.push("-i", file_mode?.path || playback_url.href); |
|
|
| if (_log) console.log("[PROBE CMD]", command, "\n"); |
|
|
| const proc = Bun.spawn(command, { |
| stdout: "pipe", |
| stderr: "pipe", |
| signal: controller.signal |
| }); |
|
|
| |
| const probePromise = (async () => { |
| const [txtResponse, stderrResponse, exitCode] = await Promise.all([ |
| new Response(proc.stdout).text(), |
| new Response(proc.stderr).text(), |
| proc.exited |
| ]); |
|
|
| return { txt: txtResponse, stderr: stderrResponse, exitCode }; |
| })(); |
|
|
| const timeoutPromise = new Promise<never>((_, reject) => { |
| setTimeout(() => reject(new Error("Probe timeout")), TIMEOUT_MS); |
| }); |
|
|
| let result; |
| try { |
| result = await Promise.race([probePromise, timeoutPromise]); |
| } catch (err) { |
| clearTimeout(t); |
| kill_controller(); |
| throw err; |
| } |
|
|
| clearTimeout(t); |
|
|
| const { txt, stderr, exitCode } = result; |
|
|
| if (exitCode !== 0) { |
| clearTimeout(t); |
| throw new Error( |
| JSON.stringify({ |
| message: `Probe failed with code [${exitCode}]: ${stderr}`, |
| code: exitCode, |
| stderr: stderr |
| }) |
| ); |
| } |
|
|
| const metadata: FfprobeData = JSON.parse(txt); |
|
|
| const streams = metadata.streams; |
|
|
| let hasSubs = false; |
|
|
| const audioStreams: any[] = []; |
| const videoStreams: any[] = []; |
| const subStreams: any[] = []; |
|
|
| |
| for (const stream of streams) { |
| if (stream.codec_type === "video") { |
| |
| videoStreams.push(stream); |
| } else if (stream.codec_type === "audio") { |
| |
| audioStreams.push(stream); |
| } else if (stream.codec_type === "subtitle") { |
| hasSubs = true; |
| subStreams.push(stream); |
| } |
| } |
|
|
| const videoStream = streams.find((s) => s.codec_type === "video"); |
| const audioStream = streams.find((s) => s.codec_type === "audio"); |
|
|
| |
|
|
| |
| const englishAudio = audioStreams.find((s) => s.tags?.language === "eng"); |
| const audioIndex = englishAudio?.index ?? audioStreams[0]?.index ?? 1; |
|
|
| |
| const englishSub = subStreams.find((s) => s.tags?.language === "eng"); |
| const subIndex = englishSub?.index ?? subStreams[0]?.index ?? null; |
|
|
| |
| const target720 = videoStreams.find( |
| (s) => s.height === 720 || s.width === 1280 |
| ); |
| const bestVideo = |
| target720 || |
| videoStreams.reduce( |
| (best, current) => |
| (current.height || 0) > (best.height || 0) ? current : best, |
| videoStreams[0] |
| ); |
|
|
| const bestVideoIndex = bestVideo?.index; |
| const bestVideoBox = bestVideo |
| ? { |
| height: bestVideo.height, |
| width: bestVideo.width |
| } |
| : undefined; |
|
|
| const probe_data: T_Probe = { |
| videoCodec: videoStream?.codec_name || "none", |
| audioCodec: audioStream?.codec_name || "none", |
| hasSubs, |
| duration: metadata.format.duration, |
| size: metadata.format.size, |
| format_name: metadata.format.format_name, |
| audioIndex, |
| subIndex, |
| bestVideoIndex, |
| bestVideoBox |
| }; |
|
|
| return { |
| probe_data, |
| metadata |
| }; |
| } |
|
|
| async function F_build_ffmpeg_command_v1(arg0: { |
| playback_headers?: Record<string, any>; |
| playback_url: URL; |
| context?: Context; |
| is_net_src?: boolean; |
| has_tcp_progress?: boolean; |
| subtitles?: Record<string, any>[]; |
| probe_data: T_Probe; |
| output_format: string; |
| live: string; |
| }) { |
| const { |
| playback_url, |
| context, |
| is_net_src, |
| has_tcp_progress, |
| subtitles, |
| probe_data, |
| output_format, |
| live |
| } = arg0; |
|
|
| let { playback_headers } = arg0; |
|
|
| |
| |
| |
| const command: string[] = ["-hide_banner", "-loglevel", "info", "-stats"]; |
|
|
| |
| |
| |
| if (is_net_src) { |
| command.push("-reconnect", "1"); |
| command.push("-reconnect_streamed", "1"); |
| command.push("-reconnect_delay_max", "5"); |
|
|
| if (has_tcp_progress) { |
| command.push("-timeout", "0"); |
| command.push("-rw_timeout", "0"); |
| command.push("-multiple_requests", "1"); |
| } else { |
| command.push("-rw_timeout", (120 * 1_000_000).toString()); |
| command.push("-timeout", (120 * 1_000_000).toString()); |
| } |
| } |
|
|
| if (is_prod && FFMPEG_V === "latest") { |
| command.push("-allowed_segment_extensions", "ALL"); |
| command.push("-allowed_extensions", "ALL"); |
| command.push("-extension_picky", "0"); |
| command.push("-protocol_whitelist", "file,http,https,tcp,tls,crypto"); |
| command.push("-strict", "experimental"); |
| } |
|
|
| |
| |
| |
| function add_headers_if_needed() { |
| const ua = NS_misc.F_get_obj_kv({ |
| name: "User-Agent", |
| obj: playback_headers |
| }); |
|
|
| if (!ua) { |
| try { |
| |
| const userAgent = |
| UserAgent.random({ deviceCategory: "desktop" }) || new UserAgent(); |
|
|
| if (!playback_headers) { |
| playback_headers = {}; |
| } |
|
|
| playback_headers["User-Agent"] = userAgent.toString(); |
| } catch (err: any) {} |
| } |
|
|
| if (playback_headers && Object.keys(playback_headers).length > 0) { |
| const ua = NS_misc.F_get_obj_kv({ |
| name: "User-Agent", |
| obj: playback_headers |
| }); |
| const ref = NS_misc.F_get_obj_kv({ |
| name: "Referer", |
| obj: playback_headers |
| }); |
|
|
| |
| |
|
|
| const other_headers = Object.entries(playback_headers) |
| .filter(([k]) => !["user-agentx", "refererx"].includes(k.toLowerCase())) |
| .map(([k, v]) => `${k}: ${v}`) |
| |
| .join("\r\n"); |
|
|
| if (other_headers) { |
| command.push("-headers", other_headers + "\r\n"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| } |
| } |
|
|
| add_headers_if_needed(); |
|
|
| command.push("-http_persistent", "0"); |
|
|
| |
| |
| |
| command.push("-i", playback_url.href); |
|
|
| |
| |
| |
| let has_native_subs = false; |
|
|
| await handle_subtitles(); |
|
|
| async function handle_subtitles() { |
| if (typeof probe_data.subIndex === "number") { |
| |
| |
| command.push("-map", `0:${probe_data.subIndex}`); |
| command.push( |
| "-c:s", |
| output_format === "ts" ? "dvb_subtitle" : "mov_text" |
| ); |
|
|
| has_native_subs = true; |
| } |
|
|
| if (!(!has_native_subs && subtitles && subtitles.length)) return; |
|
|
| let default_sub_index = subtitles.findIndex((xx) => xx.default); |
| default_sub_index = default_sub_index < 0 ? 0 : default_sub_index; |
|
|
| let burnt = false; |
| const limit = 1; |
|
|
| for (var i = 0; i < subtitles.length; i++) { |
| if (i > limit) { |
| break; |
| } |
|
|
| const subtitle = subtitles[i]; |
|
|
| const burn = subtitle.burn; |
| const path = subtitle.file_path; |
| const url = subtitle.url; |
| let lang = subtitle.lang; |
| const title = subtitle.title || subtitle.name; |
| const is_default = default_sub_index === i; |
| const subtitle_headers = subtitle.subtitle_headers; |
|
|
| const n_ = i + 1; |
|
|
| if (typeof lang === "string") { |
| lang = lang.trim().toLowerCase(); |
|
|
| if (lang.length < 2) { |
| lang = undefined; |
| } |
|
|
| if (lang.length > 3) { |
| lang = lang.slice(0, 3); |
| } |
| } else { |
| lang = undefined; |
| } |
|
|
| if (burn && path && (!burnt || is_default)) { |
| |
| const ext = path.extname(path).toLowerCase(); |
| if ([".vtt", ".srt", ".ass", ".ssa"].includes(ext)) { |
| |
| let normalized = path |
| .normalize(path) |
| .replace(/\\/g, "/") |
| .replace(":", "\\:"); |
|
|
| command.push("-i", path); |
| command.push("-sub_charenc", "UTF-8"); |
| command.push("-vf", `subtitles='${normalized}'`); |
|
|
| has_native_subs = true; |
|
|
| burnt = true; |
|
|
| continue; |
| } |
| } |
|
|
| if ( |
| !url || |
| !NS_misc.isValidUrlFormat(url) || |
| typeof lang !== "string" || |
| !(lang.length > 0 && lang.length < 4) |
| ) { |
| continue; |
| } |
|
|
| if (subtitle_headers && Object.keys(subtitle_headers).length) { |
| const headers_string = Object.entries(subtitle_headers) |
| .map(([key, value]) => `${key}: ${value}`) |
| .join("\r\n"); |
| command.push("-headers", headers_string); |
| } |
|
|
| command.push("-i", url); |
| command.push("-map", `${i}:0`); |
| command.push( |
| `-c:s:${i}`, |
| output_format === "ts" ? "dvb_subtitle" : "mov_text" |
| ); |
| command.push(`-metadata:s:s:${i}`, `language=${lang}`); |
| if (title) { |
| command.push(`-metadata:s:s:${i}`, `title=${title}`); |
| } |
| if (is_default) { |
| command.push(`-disposition:s:${i}`, `default`); |
| } |
| } |
| } |
|
|
| |
| |
| |
| if ( |
| probe_data.videoCodec === "h264" && |
| probe_data.audioCodec === "aac" && |
| !has_native_subs && |
| (output_format === "mp4" || output_format === "ts") && |
| typeof probe_data.audioIndex !== "number" |
| ) { |
| command.push("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc"); |
| } else { |
| if (typeof probe_data.bestVideoIndex === "number") { |
| command.push("-map", `0:${probe_data.bestVideoIndex}?`); |
| } else { |
| if (typeof probe_data.audioIndex === "number") { |
| command.push("-map", "0:v:0?"); |
| } |
| } |
|
|
| |
| command.push("-c:v", hw_encoder || "libx264"); |
| } |
|
|
| if (typeof probe_data.audioIndex === "number") { |
| command.push("-map", `0:${probe_data.audioIndex}?`); |
| } |
|
|
| command.push("-tune", "zerolatency"); |
| command.push("-crf", "23"); |
| command.push("-preset", live === "stream" ? "ultrafast" : "medium"); |
| command.push("-pix_fmt", "yuv420p"); |
| command.push("-c:a", "aac"); |
| command.push("-b:a", "256k"); |
|
|
| command.push("-max_interleave_delta", "0"); |
|
|
| |
| |
| |
| command.push("-f", output_format === "ts" ? "mpegts" : output_format); |
|
|
| |
| |
| |
| command.push("-avoid_negative_ts", "make_zero"); |
| command.push("-fflags", "+genpts+nobuffer"); |
| |
|
|
| if (live === "stream") { |
| |
| } |
|
|
| command.push("-fps_mode", "passthrough"); |
|
|
| if (is_net_src) { |
| command.push("-movflags", "frag_keyframe+empty_moov+default_base_moof"); |
| } else { |
| command.push("-movflags", "+faststart"); |
| } |
|
|
| return command; |
| } |
|
|
| async function F_build_ffmpeg_command_v2(arg0: { |
| playback_headers?: Record<string, any>; |
| playback_url: URL; |
| context?: Context; |
| is_net_src?: boolean; |
| has_tcp_progress?: boolean; |
| subtitle?: Record<string, any>; |
| probe_data: T_Probe; |
| output_format: string; |
| live: string; |
| }) { |
| const { |
| playback_headers, |
| playback_url, |
| context, |
| is_net_src, |
| has_tcp_progress, |
| subtitle, |
| probe_data, |
| output_format, |
| live |
| } = arg0; |
|
|
| const command = fluent_ffmpeg(); |
|
|
| |
| |
| |
| command |
| .addOption("-hide_banner") |
| .addOption("-loglevel", "info") |
| .addOption("-stats"); |
|
|
| |
| |
| |
| if (is_net_src) { |
| command.addOption("-reconnect", "1"); |
| command.addOption("-reconnect_streamed", "1"); |
| command.addOption("-reconnect_delay_max", "5"); |
|
|
| if (has_tcp_progress) { |
| command.addOption("-timeout", "0"); |
| command.addOption("-rw_timeout", "0"); |
| command.addOption("-multiple_requests", "1"); |
| } else { |
| command.addOption("-rw_timeout", (120 * 1_000_000).toString()); |
| command.addOption("-timeout", (120 * 1_000_000).toString()); |
| } |
| } |
|
|
| if (is_prod && FFMPEG_V === "latest") { |
| command.addOption("-allowed_segment_extensions", "ALL"); |
| command.addOption("-allowed_extensions", "ALL"); |
| command.addOption("-extension_picky", "0"); |
| command.addOption("-protocol_whitelist", "file,http,https,tcp,tls,crypto"); |
| command.addOption("-strict", "experimental"); |
| } |
|
|
| |
| |
| |
| if (playback_headers && Object.keys(playback_headers).length > 0) { |
| const header_string = Object.entries(playback_headers) |
| .map(([k, v]) => `${k}: ${v}`) |
| .join("\r\n"); |
|
|
| command.addOption("-headers", header_string); |
| } |
|
|
| command.addOption("-http_persistent", "0"); |
|
|
| |
| |
| |
| command.input(playback_url.href); |
|
|
| |
| |
| |
| let has_native_subs = false; |
|
|
| if (typeof probe_data.subIndex === "number") { |
| |
| |
| command.addOption("-map", `0:${probe_data.subIndex}`); |
| command.addOption( |
| "-c:s", |
| output_format === "ts" ? "dvb_subtitle" : "mov_text" |
| ); |
|
|
| has_native_subs = true; |
| } |
|
|
| await (async () => { |
| if (!(!has_native_subs && subtitle)) return; |
|
|
| const burn = subtitle.burn; |
| const path = subtitle.path; |
| const url = subtitle.url; |
| const lang = subtitle.lang; |
| const name = subtitle.name; |
| const subtitle_headers = subtitle.subtitle_headers; |
|
|
| if (burn && path) { |
| |
| const ext = path.extname(path).toLowerCase(); |
| if ([".vtt", ".srt", ".ass", ".ssa"].includes(ext)) { |
| |
| let normalized = path |
| .normalize(path) |
| .replace(/\\/g, "/") |
| .replace(":", "\\:"); |
|
|
| command.addOption("-sub_charenc", "UTF-8"); |
| command.addOption("-i", path); |
| command.addOption("-vf", `subtitles='${normalized}'`); |
|
|
| has_native_subs = true; |
|
|
| return; |
| } |
| } |
|
|
| if ( |
| !url || |
| !NS_misc.isValidUrlFormat(url) || |
| typeof lang !== "string" || |
| !(lang.trim().length > 0 && lang.trim().length < 4) |
| ) { |
| return; |
| } |
|
|
| if (subtitle_headers && Object.keys(subtitle_headers).length) { |
| const headers_string = Object.entries(subtitle_headers) |
| .map(([key, value]) => `${key}: ${value}`) |
| .join("\r\n"); |
| command.addOption("-headers", headers_string); |
| } |
|
|
| command.addOption("-metadata:s:s:0", `language=${lang.trim()}`); |
| command.addOption( |
| "-c:s", |
| output_format === "ts" ? "dvb_subtitle" : "mov_text" |
| ); |
|
|
| command.addOption("-i", url); |
| })(); |
|
|
| |
| |
| |
| if ( |
| probe_data.videoCodec === "h264" && |
| probe_data.audioCodec === "aac" && |
| !has_native_subs && |
| (output_format === "mp4" || output_format === "ts") && |
| typeof probe_data.audioIndex !== "number" |
| ) { |
| command.addOption( |
| "-c:v", |
| "copy", |
| "-c:a", |
| "copy", |
| "-bsf:a", |
| "aac_adtstoasc" |
| ); |
| } else { |
| if (typeof probe_data.bestVideoIndex === "number") { |
| command.addOption("-map", `0:${probe_data.bestVideoIndex}`); |
| } else { |
| if (typeof probe_data.audioIndex === "number") { |
| command.addOption("-map", "0:v:0"); |
| } |
| } |
|
|
| command.addOption("-c:v", "libx264"); |
| } |
|
|
| if (typeof probe_data.audioIndex === "number") { |
| command.addOption("-map", `0:${probe_data.audioIndex}`); |
| } |
|
|
| command.addOption("-tune", "zerolatency"); |
| command.addOption("-crf", "23"); |
| command.addOption("-preset", live === "stream" ? "ultrafast" : "medium"); |
| command.addOption("-pix_fmt", "yuv420p"); |
| command.addOption("-c:a", "aac"); |
| command.addOption("-b:a", "256k"); |
|
|
| command.addOption("-max_interleave_delta", "0"); |
|
|
| |
| |
| |
| command.format(output_format === "ts" ? "mpegts" : output_format); |
|
|
| |
| |
| |
| command.addOption("-avoid_negative_ts", "make_zero"); |
| command.addOption("-fflags", "+genpts+nobuffer"); |
| |
|
|
| if (live === "stream") { |
| |
| } |
|
|
| command.addOption("-fps_mode", "passthrough"); |
|
|
| if (is_net_src) { |
| command.addOption( |
| "-movflags", |
| "frag_keyframe+empty_moov+default_base_moof" |
| ); |
| } else { |
| command.addOption("-movflags", "+faststart"); |
| } |
|
|
| return command; |
| } |
|
|
| async function F_build_ffmpeg_command_v3(arg0: { |
| playback_headers?: Record<string, any>; |
| playback_url: URL; |
| playback_type?: string; |
| context?: Context; |
| is_net_src?: boolean; |
| has_tcp_progress?: boolean; |
| subtitles?: Record<string, any>[]; |
| probe_data: T_Probe; |
| output_format: "ts" | "mp4" | "mkv"; |
| live: string; |
| file_mode?: boolean; |
| re_encode_audio?: boolean; |
| }) { |
| const { |
| playback_url, |
| context, |
| is_net_src, |
| has_tcp_progress, |
| subtitles, |
| probe_data, |
| output_format, |
| live, |
| playback_type, |
| file_mode, |
| re_encode_audio |
| } = arg0; |
|
|
| let { playback_headers } = arg0; |
|
|
| |
| |
| |
| const command: string[] = ["-hide_banner", "-loglevel", "info", "-stats"]; |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| const timeoutVal = has_tcp_progress ? "0" : (80 * 1_000_000).toString(); |
|
|
| if (is_net_src) { |
| command.push("-reconnect", "1"); |
| command.push("-reconnect_streamed", "1"); |
| command.push("-reconnect_delay_max", "20"); |
| command.push("-reconnect_on_network_error", "1"); |
| command.push("-reconnect_on_http_error", "429,500,502,503,504"); |
|
|
| |
| |
| command.push("-rw_timeout", timeoutVal, "-timeout", timeoutVal); |
|
|
| if (has_tcp_progress) { |
| command.push("-multiple_requests", "1"); |
| } |
| } |
|
|
| if (is_prod && FFMPEG_V === "latest") { |
| command.push("-allowed_segment_extensions", "ALL"); |
| command.push("-allowed_extensions", "ALL"); |
| command.push("-extension_picky", "0"); |
| command.push("-protocol_whitelist", "file,http,https,tcp,tls,crypto"); |
| command.push("-strict", "experimental"); |
| |
| } |
|
|
| |
| |
| |
| function add_headers_if_needed() { |
| const ua = NS_misc.F_get_obj_kv({ |
| name: "User-Agent", |
| obj: playback_headers |
| }); |
|
|
| if (!ua) { |
| try { |
| const userAgent = |
| UserAgent.random({ deviceCategory: "desktop" }) || new UserAgent(); |
|
|
| if (!playback_headers) { |
| playback_headers = {}; |
| } |
|
|
| playback_headers["User-Agent"] = userAgent.toString(); |
| } catch (err: any) {} |
| } |
|
|
| if (playback_headers && Object.keys(playback_headers).length > 0) { |
| const ua = NS_misc.F_get_obj_kv({ |
| name: "User-Agent", |
| obj: playback_headers |
| }); |
| const ref = NS_misc.F_get_obj_kv({ |
| name: "Referer", |
| obj: playback_headers |
| }); |
|
|
| if (ua) command.push("-user_agent", ua); |
| if (ref) command.push("-referer", ref); |
|
|
| const other_headers = Object.entries(playback_headers) |
| .filter(([k]) => !["user-agent", "referer"].includes(k.toLowerCase())) |
| .map(([k, v]) => `${k}: ${v}`) |
| .join("\r\n"); |
|
|
| if (other_headers) { |
| command.push("-headers", other_headers + "\r\n"); |
| } |
| } |
| } |
|
|
| add_headers_if_needed(); |
|
|
| |
| |
| |
| |
| |
|
|
| command.push("-fflags", "+discardcorrupt+genpts+nobuffer"); |
| |
|
|
| if ( |
| playback_type === "hls" || |
| (typeof playback_type === "string" && playback_type.includes("hls")) || |
| (typeof probe_data.format_name === "string" && |
| probe_data.format_name.includes("hls")) |
| ) { |
| command.push("-http_persistent", "1"); |
| } |
|
|
| command.push("-i", playback_url.href); |
|
|
| |
| |
| |
| const all_subs = Array.isArray(subtitles) ? subtitles : []; |
|
|
| const valid_external_subs = (() => { |
| const limit = 4; |
| let soft_subs = all_subs.filter((s) => s.url && !s.burn); |
|
|
| return get_filtered_subs({ limit, subs: soft_subs }); |
| })(); |
|
|
| const valid_bun_subs = (() => { |
| const limit = 1; |
| let burn_subs = all_subs.filter((s) => s.path && s.burn); |
|
|
| return get_filtered_subs({ limit, subs: burn_subs }); |
| })(); |
|
|
| function get_filtered_subs(arg0: { limit: number; subs: any[] }) { |
| const { limit, subs: originalSubs } = arg0; |
|
|
| let subs = [...originalSubs]; |
|
|
| if (!limit || !(subs.length > limit)) return subs; |
|
|
| const defaultIdx = subs.findIndex((s) => { |
| const hasValidUrl = |
| typeof s?.url === "string" && NS_misc.isValidUrlFormat(s.url); |
|
|
| return hasValidUrl && s.default; |
| }); |
|
|
| let defaultSub = null; |
|
|
| if (defaultIdx > -1) { |
| [defaultSub] = subs.splice(defaultIdx, 1); |
| } |
|
|
| const englishRegex = /\b(en|eng|english)\b/i; |
|
|
| const englishSubs = []; |
| const otherSubs = []; |
|
|
| for (const s of subs) { |
| const matchesEnglish = [s.lang, s.title, s.name].some( |
| (field) => typeof field === "string" && englishRegex.test(field) |
| ); |
|
|
| const hasValidUrl = |
| typeof s?.url === "string" && NS_misc.isValidUrlFormat(s.url); |
|
|
| if (!hasValidUrl) continue; |
|
|
| if (matchesEnglish) { |
| englishSubs.push(s); |
| } else { |
| otherSubs.push(s); |
| } |
| } |
|
|
| const result = []; |
| if (defaultSub) result.push(defaultSub); |
|
|
| let combined = [...result, ...englishSubs, ...otherSubs]; |
|
|
| if (limit) { |
| combined = combined.slice(0, limit); |
| } |
|
|
| return combined; |
| } |
|
|
| for (const sub of valid_external_subs) { |
| const subtitle_headers = sub.subtitle_headers; |
| const url = sub.url; |
|
|
| if (subtitle_headers && Object.keys(subtitle_headers).length) { |
| const h_str = Object.entries(subtitle_headers) |
| .map(([k, v]) => `${k}: ${v}`) |
| .join("\r\n"); |
| command.push("-headers", h_str); |
| } |
|
|
| command.push("-i", url); |
| } |
|
|
| |
| |
| |
| const is_burning = |
| valid_bun_subs && |
| Array.isArray(valid_bun_subs) && |
| valid_bun_subs.length > 0; |
|
|
| const must_re_encode = |
| valid_bun_subs?.length || |
| probe_data.videoCodec !== "h264" || |
| probe_data.audioCodec !== "aac" || |
| typeof probe_data.subIndex === "number" || |
| (output_format !== "mp4" && output_format !== "ts"); |
|
|
| |
| if (typeof probe_data.bestVideoIndex === "number") { |
| command.push("-map", `0:${probe_data.bestVideoIndex}?`); |
| } else { |
| command.push("-map", "0:v:0?"); |
| } |
|
|
| if (must_re_encode) { |
| command.push("-c:v", hw_encoder || "libx264"); |
|
|
| if (is_burning && valid_bun_subs[0].path) { |
| const safePath = valid_bun_subs[0].path |
| .replace(/\\/g, "/") |
| .replace(/:/g, "\\:"); |
| command.push("-vf", `subtitles='${safePath}'`); |
| } |
| } else { |
| command.push("-c:v", "copy"); |
| } |
|
|
| |
| if (typeof probe_data.audioIndex === "number") { |
| command.push("-map", `0:${probe_data.audioIndex}?`); |
| } else { |
| command.push("-map", "0:a:0"); |
| } |
|
|
| if (must_re_encode || re_encode_audio) { |
| command.push("-c:a", "aac", "-b:a", "256k"); |
| |
| command.push("-af", "loudnorm=I=-14:TP=-1.5:LRA=11,aresample=async=1"); |
| } else { |
| command.push("-c:a", "copy", "-bsf:a", "aac_adtstoasc"); |
| } |
|
|
| |
| let out_sub_idx = 0; |
| let has_assigned_default = false; |
|
|
| const sub_claims_default = |
| valid_external_subs.some((s) => s.default) || |
| typeof probe_data.subIndex === "number"; |
|
|
| |
| if (typeof probe_data.subIndex === "number") { |
| command.push("-map", `0:${probe_data.subIndex}`); |
| command.push( |
| `-c:s:${out_sub_idx}`, |
| output_format === "ts" ? "dvb_subtitle" : "mov_text" |
| ); |
|
|
| command.push(`-disposition:s:${out_sub_idx}`, "default"); |
| has_assigned_default = true; |
| out_sub_idx++; |
| } |
|
|
| |
| for (let i = 0; i < valid_external_subs.length; i++) { |
| const sub = valid_external_subs[i]; |
| const input_idx = i + 1; |
|
|
| const title = sub.title || sub.name; |
| const is_default = sub.default; |
| let lang = (sub.lang || "und").trim(); |
| lang = lang.slice(0, 3).toLowerCase(); |
|
|
| const should_be_default = |
| !has_assigned_default && (is_default || (!sub_claims_default && i === 0)); |
|
|
| command.push("-map", `${input_idx}:0`); |
| command.push( |
| `-c:s:${out_sub_idx}`, |
| output_format === "ts" ? "dvb_subtitle" : "mov_text" |
| ); |
|
|
| |
| command.push(`-metadata:s:s:${out_sub_idx}`, `language=${lang}`); |
| if (title) { |
| command.push(`-metadata:s:s:${out_sub_idx}`, `title=${title}`); |
| } |
|
|
| |
| if (should_be_default) { |
| command.push(`-disposition:s:${out_sub_idx}`, "default"); |
| has_assigned_default = true; |
| } else { |
| command.push(`-disposition:s:${out_sub_idx}`, "0"); |
| } |
|
|
| out_sub_idx++; |
| } |
|
|
| const use_ultrafast = live === "stream" || valid_bun_subs.length; |
|
|
| |
| if (live === "stream") { |
| command.push("-tune", "zerolatency"); |
| } |
|
|
| |
| if(hw_encoder.includes("h264_qsv")) { |
| command.push("-preset", use_ultrafast ? "7" : "7"); |
| } else { |
| command.push("-crf", "23"); |
| command.push("-preset", use_ultrafast ? "ultrafast" : "ultrafast"); |
| } |
| |
| if (must_re_encode) command.push("-pix_fmt", "yuv420p"); |
| command.push("-max_interleave_delta", "0"); |
|
|
| |
|
|
| if (live === "stream") { |
| command.push("-f", "ismv"); |
| } else { |
| command.push("-f", output_format === "ts" ? "mpegts" : output_format); |
| } |
|
|
| command.push("-avoid_negative_ts", "make_zero"); |
|
|
| if (live === "stream") { |
| |
| } |
|
|
| if (file_mode) { |
| |
| command.push("-max_muxing_queue_size", "9999"); |
| } else { |
| command.push("-fps_mode", "passthrough"); |
| } |
|
|
| if (is_net_src && !file_mode) { |
| |
| command.push("-movflags", "frag_keyframe+dash+delay_moov"); |
| } else { |
| command.push("-movflags", "+faststart"); |
| } |
|
|
| return command; |
| } |
|
|
| function F_stderr_data_read(arg0: { output: string }) { |
| const { output } = arg0; |
| let duration = 0; |
| let current_time = 0; |
| let current_size_bytes = 0; |
| let progress = 0; |
|
|
| |
| const durationMatch = output.match( |
| /Duration:\s*(\d{2}):(\d{2}):(\d{2}\.\d{2})/ |
| ); |
| if (durationMatch) { |
| const [, hours, minutes, seconds] = durationMatch; |
| duration = |
| parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseFloat(seconds); |
| } |
|
|
| |
| const timeMatch = output.match(/time\s*=\s*(\d{2}):(\d{2}):(\d{2}\.\d{2})/); |
| if (timeMatch) { |
| const [, hours, minutes, seconds] = timeMatch; |
| current_time = |
| parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseFloat(seconds); |
| } |
|
|
| |
| const sizeMatch = output.match( |
| /[Ll]?size\s*=\s*(\d+(?:\.\d+)?)\s*([kmgtKMGT]?[bB]?)/ |
| ); |
| if (sizeMatch) { |
| const [, sizeValue, unit] = sizeMatch; |
| let sizeInBytes = parseFloat(sizeValue); |
|
|
| |
| const unitLower = unit.toLowerCase(); |
| switch (unitLower) { |
| case "kb": |
| sizeInBytes *= 1024; |
| break; |
| case "mb": |
| sizeInBytes *= 1024 * 1024; |
| break; |
| case "gb": |
| sizeInBytes *= 1024 * 1024 * 1024; |
| break; |
| case "tb": |
| sizeInBytes *= 1024 * 1024 * 1024 * 1024; |
| break; |
| |
| } |
|
|
| current_size_bytes = Math.round(sizeInBytes); |
| } |
|
|
| |
| if (duration && current_time) { |
| progress = Math.min( |
| parseFloat(((current_time / duration) * 100).toFixed(2)), |
| 100 |
| ); |
|
|
| let progress_info = `${progress}%`; |
| if (current_size_bytes !== undefined) { |
| |
| const formatSize = (bytes: number): string => { |
| if (bytes < 1024) return `${bytes}B`; |
| if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; |
| if (bytes < 1024 * 1024 * 1024) |
| return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; |
| return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`; |
| }; |
|
|
| |
| } |
| } |
|
|
| return { |
| video_duration: duration, |
| current_out_time: current_time, |
| current_size_bytes |
| }; |
| } |
|
|
| function F_format_duration(seconds?: number): string { |
| if (typeof seconds !== "number") { |
| return ""; |
| } |
|
|
| if (isNaN(seconds) || seconds < 0) return "0:00"; |
|
|
| const hrs = Math.floor(seconds / 3600); |
| const mins = Math.floor((seconds % 3600) / 60); |
| const secs = Math.floor(seconds % 60); |
|
|
| let data = ""; |
|
|
| if (hrs > 0) { |
| data = `${hrs}:${mins.toString().padStart(2, "0")}:${secs |
| .toString() |
| .padStart(2, "0")}`; |
| } else { |
| data = `${mins}:${secs.toString().padStart(2, "0")}`; |
| } |
|
|
| return data; |
| } |
|
|
| function F_format_size(bytes?: number) { |
| if (typeof bytes !== "number") { |
| return; |
| } |
|
|
| if (bytes < 1024) return `${bytes} B`; |
| if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; |
| if (bytes < 1024 * 1024 * 1024) |
| return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; |
| return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; |
| } |
|
|
| export const NS_ffmpeg = { |
| fluent_ffmpeg, |
| ffmpeg_path, |
| ffprobe_path, |
| TIMEOUT_MS, |
| PROBE_TIMEOUT_MS, |
| F_probe_v1, |
| F_probe_v2, |
| F_build_ffmpeg_command_v1, |
| F_build_ffmpeg_command_v2, |
| F_build_ffmpeg_command_v3, |
| F_format_duration, |
| F_format_size, |
| F_stderr_data_read, |
| get_hardware_encoder |
| }; |
|
|