339 lines
10 KiB
Lua
339 lines
10 KiB
Lua
local _M = {}
|
|
|
|
local THUMB_DIR = "/tmp/thumbs"
|
|
local GENERATION_INTERVAL = 600 -- 10 minutes in seconds
|
|
local NEW_STREAM_DELAY = 60 -- 1 minute delay for new streams
|
|
local NEW_STREAM_CHECK_INTERVAL = 10 -- Check for new streams every 10 seconds
|
|
local FFMPEG_TIMEOUT = 15 -- seconds (needs more time for animated capture)
|
|
local ANIMATION_DURATION = 3 -- seconds of video to capture
|
|
local ANIMATION_FPS = 8 -- frames per second in output
|
|
|
|
-- Track known live streams (in-memory, resets on nginx restart)
|
|
local known_streams = {}
|
|
-- Track streams with pending thumbnail generation
|
|
local pending_streams = {}
|
|
|
|
-- SECURITY FIX: Shell escape function to prevent command injection
|
|
local function shell_escape(str)
|
|
-- Escape single quotes by ending the quoted string, adding escaped quote, and starting new quoted string
|
|
return "'" .. string.gsub(str, "'", "'\\''") .. "'"
|
|
end
|
|
|
|
local function file_exists(path)
|
|
local f = io.open(path, "r")
|
|
if f then
|
|
f:close()
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function file_size(path)
|
|
local f = io.open(path, "rb")
|
|
if f then
|
|
local size = f:seek("end")
|
|
f:close()
|
|
return size or 0
|
|
end
|
|
return 0
|
|
end
|
|
|
|
-- Lookup stream_key from realm name via internal API
|
|
local function get_stream_key_for_realm(realm_name)
|
|
local http = require "resty.http"
|
|
local httpc = http.new()
|
|
|
|
local res, err = httpc:request_uri("http://drogon-backend:8080/internal/realm-stream-key/" .. ngx.escape_uri(realm_name), {
|
|
method = "GET",
|
|
headers = { ["Content-Type"] = "application/json" }
|
|
})
|
|
|
|
if not res then
|
|
ngx.log(ngx.ERR, "Failed to lookup stream key for realm ", realm_name, ": ", err)
|
|
return nil
|
|
end
|
|
|
|
if res.status ~= 200 then
|
|
return nil
|
|
end
|
|
|
|
local cjson = require "cjson"
|
|
local ok, data = pcall(cjson.decode, res.body)
|
|
if not ok or not data.streamKey then
|
|
return nil
|
|
end
|
|
|
|
return data.streamKey
|
|
end
|
|
|
|
-- Get all live realms from API
|
|
local function get_live_realms()
|
|
local http = require "resty.http"
|
|
local httpc = http.new()
|
|
|
|
local res, err = httpc:request_uri("http://drogon-backend:8080/api/realms/live", {
|
|
method = "GET",
|
|
headers = { ["Content-Type"] = "application/json" }
|
|
})
|
|
|
|
if not res then
|
|
ngx.log(ngx.ERR, "Failed to get live realms: ", err)
|
|
return {}
|
|
end
|
|
|
|
if res.status ~= 200 then
|
|
ngx.log(ngx.ERR, "Failed to get live realms: status ", res.status)
|
|
return {}
|
|
end
|
|
|
|
local cjson = require "cjson"
|
|
local ok, realms = pcall(cjson.decode, res.body)
|
|
if not ok then
|
|
ngx.log(ngx.ERR, "Failed to parse live realms response")
|
|
return {}
|
|
end
|
|
|
|
return realms
|
|
end
|
|
|
|
-- Generate thumbnail for a single stream
|
|
local function generate_thumbnail(stream_key)
|
|
-- Build LLHLS URL - internal docker network
|
|
local llhls_url = "http://ovenmediaengine:8080/app/" .. stream_key .. "/llhls.m3u8"
|
|
local thumb_path = THUMB_DIR .. "/" .. stream_key .. ".webp"
|
|
local log_file = THUMB_DIR .. "/" .. stream_key .. ".log"
|
|
|
|
-- First, try to generate animated WebP
|
|
local cmd = string.format(
|
|
"timeout %d ffmpeg -y -i %s -t %d -vf 'fps=%d,scale=320:-1:flags=lanczos' -c:v libwebp -lossless 0 -compression_level 3 -q:v 70 -loop 0 -preset default -an %s > %s 2>&1",
|
|
FFMPEG_TIMEOUT,
|
|
shell_escape(llhls_url),
|
|
ANIMATION_DURATION,
|
|
ANIMATION_FPS,
|
|
shell_escape(thumb_path),
|
|
shell_escape(log_file)
|
|
)
|
|
|
|
ngx.log(ngx.INFO, "Generating thumbnail for stream: ", stream_key)
|
|
|
|
local exit_code = os.execute(cmd)
|
|
|
|
-- Check if file was created and has content
|
|
if file_exists(thumb_path) and file_size(thumb_path) > 100 then
|
|
ngx.log(ngx.INFO, "Thumbnail generated successfully: ", thumb_path)
|
|
os.remove(log_file)
|
|
return true
|
|
end
|
|
|
|
-- If animated webp failed, try static webp (single frame)
|
|
ngx.log(ngx.WARN, "Animated webp failed (exit: ", tostring(exit_code), "), trying static")
|
|
|
|
local static_cmd = string.format(
|
|
"timeout %d ffmpeg -y -i %s -vframes 1 -vf 'scale=320:-1:flags=lanczos' -c:v libwebp -q:v 75 %s > %s 2>&1",
|
|
FFMPEG_TIMEOUT,
|
|
shell_escape(llhls_url),
|
|
shell_escape(thumb_path),
|
|
shell_escape(log_file)
|
|
)
|
|
|
|
exit_code = os.execute(static_cmd)
|
|
|
|
if file_exists(thumb_path) and file_size(thumb_path) > 100 then
|
|
ngx.log(ngx.INFO, "Static thumbnail generated: ", thumb_path)
|
|
os.remove(log_file)
|
|
return true
|
|
end
|
|
|
|
ngx.log(ngx.ERR, "Thumbnail generation failed for stream: ", stream_key)
|
|
return false
|
|
end
|
|
|
|
-- Generate thumbnail for a specific new stream (delayed callback)
|
|
local function generate_new_stream_thumbnail(premature, realm_name, stream_key)
|
|
if premature then
|
|
return
|
|
end
|
|
|
|
-- Clear pending flag
|
|
pending_streams[realm_name] = nil
|
|
|
|
ngx.log(ngx.INFO, "Generating initial thumbnail for new stream: ", realm_name)
|
|
|
|
-- Ensure thumb directory exists
|
|
os.execute("mkdir -p " .. THUMB_DIR)
|
|
|
|
-- Generate the thumbnail
|
|
if generate_thumbnail(stream_key) then
|
|
ngx.log(ngx.INFO, "Initial thumbnail generated for new stream: ", realm_name)
|
|
else
|
|
ngx.log(ngx.WARN, "Failed to generate initial thumbnail for new stream: ", realm_name)
|
|
end
|
|
end
|
|
|
|
-- Check for new streams and schedule thumbnail generation
|
|
local function check_for_new_streams(premature)
|
|
if premature then
|
|
return
|
|
end
|
|
|
|
local realms = get_live_realms()
|
|
local current_streams = {}
|
|
|
|
for _, realm in ipairs(realms) do
|
|
if realm.name then
|
|
current_streams[realm.name] = true
|
|
|
|
-- Check if this is a new stream we haven't seen
|
|
if not known_streams[realm.name] and not pending_streams[realm.name] then
|
|
ngx.log(ngx.INFO, "New stream detected: ", realm.name, " - scheduling thumbnail in ", NEW_STREAM_DELAY, " seconds")
|
|
|
|
local stream_key = get_stream_key_for_realm(realm.name)
|
|
if stream_key then
|
|
-- Mark as pending to avoid duplicate scheduling
|
|
pending_streams[realm.name] = true
|
|
|
|
-- Schedule thumbnail generation after delay
|
|
local ok, err = ngx.timer.at(NEW_STREAM_DELAY, generate_new_stream_thumbnail, realm.name, stream_key)
|
|
if not ok then
|
|
ngx.log(ngx.ERR, "Failed to schedule new stream thumbnail: ", err)
|
|
pending_streams[realm.name] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Mark stream as known
|
|
known_streams[realm.name] = true
|
|
end
|
|
end
|
|
|
|
-- Clean up streams that are no longer live
|
|
for name, _ in pairs(known_streams) do
|
|
if not current_streams[name] then
|
|
known_streams[name] = nil
|
|
pending_streams[name] = nil
|
|
end
|
|
end
|
|
|
|
-- Schedule next check
|
|
local ok, err = ngx.timer.at(NEW_STREAM_CHECK_INTERVAL, check_for_new_streams)
|
|
if not ok then
|
|
ngx.log(ngx.ERR, "Failed to schedule next new stream check: ", err)
|
|
end
|
|
end
|
|
|
|
-- Background job: Generate thumbnails for all live streams
|
|
local function generate_all_thumbnails(premature)
|
|
if premature then
|
|
return
|
|
end
|
|
|
|
ngx.log(ngx.INFO, "Starting thumbnail generation for all live streams")
|
|
|
|
-- Ensure thumb directory exists
|
|
os.execute("mkdir -p " .. THUMB_DIR)
|
|
|
|
local realms = get_live_realms()
|
|
local count = 0
|
|
|
|
for _, realm in ipairs(realms) do
|
|
if realm.name then
|
|
local stream_key = get_stream_key_for_realm(realm.name)
|
|
if stream_key then
|
|
generate_thumbnail(stream_key)
|
|
count = count + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
ngx.log(ngx.INFO, "Thumbnail generation complete. Generated ", count, " thumbnails")
|
|
|
|
-- Schedule next run
|
|
local ok, err = ngx.timer.at(GENERATION_INTERVAL, generate_all_thumbnails)
|
|
if not ok then
|
|
ngx.log(ngx.ERR, "Failed to schedule next thumbnail generation: ", err)
|
|
end
|
|
end
|
|
|
|
-- Initialize background thumbnail generation (call from init_worker_by_lua_block)
|
|
function _M.init_worker()
|
|
-- Only run on worker 0 to avoid duplicate work
|
|
if ngx.worker.id() ~= 0 then
|
|
return
|
|
end
|
|
|
|
ngx.log(ngx.INFO, "Initializing thumbnail generator on worker 0")
|
|
|
|
-- Ensure thumb directory exists
|
|
os.execute("mkdir -p " .. THUMB_DIR)
|
|
|
|
-- Start first full generation after 10 seconds (let services start up)
|
|
local ok, err = ngx.timer.at(10, generate_all_thumbnails)
|
|
if not ok then
|
|
ngx.log(ngx.ERR, "Failed to start thumbnail generator: ", err)
|
|
end
|
|
|
|
-- Start new stream detection after 15 seconds
|
|
ok, err = ngx.timer.at(15, check_for_new_streams)
|
|
if not ok then
|
|
ngx.log(ngx.ERR, "Failed to start new stream detector: ", err)
|
|
end
|
|
end
|
|
|
|
-- Serve existing thumbnail (no generation on request)
|
|
function _M.serve()
|
|
-- Get realm name from nginx variable
|
|
local realm_name = ngx.var.realm_name
|
|
if not realm_name or realm_name == "" then
|
|
ngx.status = 400
|
|
ngx.header["Content-Type"] = "text/plain"
|
|
ngx.say("Missing realm name")
|
|
return ngx.exit(400)
|
|
end
|
|
|
|
-- Sanitize realm name (alphanumeric, dash only)
|
|
if not realm_name:match("^[%w%-]+$") then
|
|
ngx.status = 400
|
|
ngx.header["Content-Type"] = "text/plain"
|
|
ngx.say("Invalid realm name")
|
|
return ngx.exit(400)
|
|
end
|
|
|
|
-- Lookup stream key from realm name
|
|
local stream_key = get_stream_key_for_realm(realm_name)
|
|
if not stream_key then
|
|
ngx.status = 404
|
|
ngx.header["Content-Type"] = "text/plain"
|
|
ngx.say("Realm not found or not live")
|
|
return ngx.exit(404)
|
|
end
|
|
|
|
local thumb_path = THUMB_DIR .. "/" .. stream_key .. ".webp"
|
|
|
|
-- Check if thumbnail exists
|
|
if not file_exists(thumb_path) or file_size(thumb_path) < 100 then
|
|
ngx.status = 404
|
|
ngx.header["Content-Type"] = "text/plain"
|
|
ngx.say("Thumbnail not available")
|
|
return ngx.exit(404)
|
|
end
|
|
|
|
-- Serve the thumbnail file
|
|
ngx.header["Content-Type"] = "image/webp"
|
|
ngx.header["Cache-Control"] = "public, max-age=60" -- Cache for 1 minute on client
|
|
ngx.header["Access-Control-Allow-Origin"] = "*"
|
|
|
|
local f = io.open(thumb_path, "rb")
|
|
if not f then
|
|
ngx.status = 500
|
|
ngx.header["Content-Type"] = "text/plain"
|
|
ngx.say("Failed to read thumbnail")
|
|
return ngx.exit(500)
|
|
end
|
|
|
|
local content = f:read("*a")
|
|
f:close()
|
|
|
|
ngx.print(content)
|
|
end
|
|
|
|
return _M
|