This commit is contained in:
parent
24d9a945b3
commit
c2bcc86527
12 changed files with 252 additions and 83 deletions
|
|
@ -1,7 +1,7 @@
|
|||
local _M = {}
|
||||
|
||||
local THUMB_DIR = "/tmp/thumbs"
|
||||
local CACHE_TTL = 5 -- seconds before regenerating
|
||||
local GENERATION_INTERVAL = 600 -- 10 minutes in 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
|
||||
|
|
@ -31,31 +31,72 @@ local function file_size(path)
|
|||
return 0
|
||||
end
|
||||
|
||||
local function file_age(path)
|
||||
-- Get file modification time using stat
|
||||
local handle = io.popen("stat -c %Y " .. path .. " 2>/dev/null")
|
||||
if not handle then
|
||||
return nil
|
||||
end
|
||||
local mtime = handle:read("*a")
|
||||
handle:close()
|
||||
-- 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()
|
||||
|
||||
if not mtime or mtime == "" then
|
||||
local res, err = httpc:request_uri("http://backend:3000/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
|
||||
|
||||
local now = os.time()
|
||||
return now - tonumber(mtime)
|
||||
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
|
||||
|
||||
local function generate_thumbnail(stream_key, thumb_path)
|
||||
-- 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://backend:3000/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
|
||||
-- Using os.execute which blocks properly until complete
|
||||
-- SECURITY FIX: Use shell_escape to prevent command injection
|
||||
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,
|
||||
|
|
@ -80,7 +121,6 @@ local function generate_thumbnail(stream_key, thumb_path)
|
|||
-- If animated webp failed, try static webp (single frame)
|
||||
ngx.log(ngx.WARN, "Animated webp failed (exit: ", tostring(exit_code), "), trying static")
|
||||
|
||||
-- SECURITY FIX: Use shell_escape to prevent command injection
|
||||
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,
|
||||
|
|
@ -97,70 +137,103 @@ local function generate_thumbnail(stream_key, thumb_path)
|
|||
return true
|
||||
end
|
||||
|
||||
-- Read log file for error info
|
||||
local log_content = ""
|
||||
local log_f = io.open(log_file, "r")
|
||||
if log_f then
|
||||
log_content = log_f:read("*a") or ""
|
||||
log_f:close()
|
||||
end
|
||||
|
||||
ngx.log(ngx.ERR, "Thumbnail generation failed (exit: ", tostring(exit_code), "): ", log_content:sub(-500))
|
||||
ngx.log(ngx.ERR, "Thumbnail generation failed for stream: ", stream_key)
|
||||
return false
|
||||
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 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
|
||||
end
|
||||
|
||||
-- Serve existing thumbnail (no generation on request)
|
||||
function _M.serve()
|
||||
-- Get stream key from nginx variable
|
||||
local stream_key = ngx.var.stream_key
|
||||
if not stream_key or stream_key == "" then
|
||||
-- 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 stream key")
|
||||
ngx.say("Missing realm name")
|
||||
return ngx.exit(400)
|
||||
end
|
||||
|
||||
-- Sanitize stream key (alphanumeric, dash, underscore only)
|
||||
if not stream_key:match("^[%w%-_]+$") then
|
||||
-- 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 stream key")
|
||||
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 cached thumbnail exists and is fresh
|
||||
local age = file_age(thumb_path)
|
||||
local needs_refresh = not age or age > CACHE_TTL
|
||||
|
||||
if needs_refresh then
|
||||
-- Generate new thumbnail
|
||||
local ok = generate_thumbnail(stream_key, thumb_path)
|
||||
if not ok then
|
||||
-- If generation failed, check if we have a stale one to serve
|
||||
if not file_exists(thumb_path) or file_size(thumb_path) < 100 then
|
||||
ngx.status = 503
|
||||
ngx.header["Content-Type"] = "text/plain"
|
||||
ngx.header["Retry-After"] = "5"
|
||||
ngx.say("Thumbnail generation in progress")
|
||||
return ngx.exit(503)
|
||||
end
|
||||
-- Serve stale thumbnail
|
||||
end
|
||||
end
|
||||
|
||||
-- Verify file exists and has content
|
||||
-- Check if thumbnail exists
|
||||
if not file_exists(thumb_path) or file_size(thumb_path) < 100 then
|
||||
ngx.status = 503
|
||||
ngx.status = 404
|
||||
ngx.header["Content-Type"] = "text/plain"
|
||||
ngx.header["Retry-After"] = "5"
|
||||
ngx.say("Thumbnail not ready")
|
||||
return ngx.exit(503)
|
||||
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=" .. CACHE_TTL
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -33,7 +33,13 @@ http {
|
|||
lua_shared_dict rate_limit 10m;
|
||||
lua_shared_dict fingerprints 10m; # Server-side fingerprint cache
|
||||
lua_shared_dict uberban_cache 1m; # Uberban status cache (5 second TTL)
|
||||
|
||||
|
||||
# Initialize background thumbnail generator (runs every 10 minutes)
|
||||
init_worker_by_lua_block {
|
||||
local thumbnail = require "thumbnail"
|
||||
thumbnail.init_worker()
|
||||
}
|
||||
|
||||
# Enable compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
|
|
@ -827,8 +833,9 @@ http {
|
|||
}
|
||||
|
||||
# Stream thumbnails - 3 second animated WebP generated on-demand via FFmpeg
|
||||
# URL uses realm name for security, Lua looks up stream_key internally
|
||||
location ~ ^/thumb/([^/]+)\.webp$ {
|
||||
set $stream_key $1;
|
||||
set $realm_name $1;
|
||||
|
||||
# CORS headers for preflight
|
||||
if ($request_method = 'OPTIONS') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue