feat: add playback-health script for buffering/drop warnings

This commit is contained in:
2025-12-06 20:09:32 +01:00
parent b5ca9ef58e
commit a4161daf47

202
scripts/playback-health.lua Normal file
View File

@@ -0,0 +1,202 @@
--[[
playback-health.lua - monitors playback health and warns on problems.
Features:
* Shows OSD warnings only when issues occur (buffering, frame drops, low cache).
* Silent during normal playback - no clutter when everything is fine.
* Detects network streams automatically; cache warnings only apply to streams.
* Frame drop monitoring works for all sources (local and network).
* Cooldown prevents warning spam; optional recovery message when issues resolve.
Configuration:
Edit the `config` table below to tune thresholds and display options.
]]
local config = {
cache_warning_seconds = 5, -- warn when cache falls below this (streams only)
drop_rate_threshold = 1.0, -- drops per second to trigger warning
drop_window_seconds = 5, -- time window for measuring drop rate
osd_duration = 3, -- how long warnings stay visible
show_buffering_percent = true, -- show buffer % while buffering
show_recovery = true, -- show recovery message when issues resolve
cooldown_seconds = 10, -- minimum time between repeated warnings of same type
monitor_cache = true, -- enable low cache warnings (streams)
monitor_drops = true, -- enable frame drop warnings (all sources)
log_debug = false,
}
-- No edits are required below unless you want to change script behavior.
local mp = require("mp")
local msg = require("mp.msg")
local state = {
is_stream = false,
buffering = false,
last_drop_count = 0,
last_drop_check = 0,
last_warning = {},
active_warning = nil,
check_timer = nil,
}
local function dbg(text)
if config.log_debug then
msg.info("playback-health: " .. text)
end
end
local function is_network_path(path)
if not path then
return false
end
return path:match("^https?://")
or path:match("^rtmps?://")
or path:match("^rtsps?://")
or path:match("^mms://")
or path:match("^mmsh://")
end
local function should_warn(warning_type)
local now = mp.get_time()
local last = state.last_warning[warning_type]
if last and (now - last) < config.cooldown_seconds then
return false
end
return true
end
local function show_warning(warning_type, message)
if not should_warn(warning_type) then
dbg(("suppressed %s warning (cooldown)"):format(warning_type))
return
end
state.last_warning[warning_type] = mp.get_time()
state.active_warning = warning_type
mp.osd_message("" .. message, config.osd_duration)
dbg(("warning: %s"):format(message))
end
local function show_recovery()
if state.active_warning and config.show_recovery then
mp.osd_message("✓ Recovered", config.osd_duration)
dbg("recovery shown")
end
state.active_warning = nil
end
local function reset_state()
state.buffering = false
state.last_drop_count = 0
state.last_drop_check = 0
state.last_warning = {}
state.active_warning = nil
end
local function on_path_change(_, path)
state.is_stream = is_network_path(path)
reset_state()
if path then
dbg(("loaded: %s (stream=%s)"):format(path, tostring(state.is_stream)))
end
end
local function on_paused_for_cache(_, paused)
if not state.is_stream then
return
end
if paused and not state.buffering then
state.buffering = true
local pct = mp.get_property_number("cache-buffering-state", 0)
if config.show_buffering_percent then
show_warning("buffering", ("Buffering... %d%%"):format(pct))
else
show_warning("buffering", "Buffering...")
end
elseif not paused and state.buffering then
state.buffering = false
show_recovery()
end
end
local function on_cache_buffering_state(_, pct)
if not state.is_stream or not state.buffering then
return
end
if pct and config.show_buffering_percent then
mp.osd_message(("⚠ Buffering... %d%%"):format(pct), config.osd_duration)
end
end
local function on_cache_duration(_, duration)
if not state.is_stream or not config.monitor_cache then
return
end
if state.buffering then
return
end
if duration and duration < config.cache_warning_seconds and duration > 0 then
show_warning("cache", ("Low cache: %.1fs remaining"):format(duration))
end
end
local function check_drop_rate()
if not config.monitor_drops then
return
end
local now = mp.get_time()
local drops = mp.get_property_number("drop-frame-count", 0)
if state.last_drop_check == 0 then
state.last_drop_count = drops
state.last_drop_check = now
return
end
local elapsed = now - state.last_drop_check
if elapsed < config.drop_window_seconds then
return
end
local new_drops = drops - state.last_drop_count
local rate = new_drops / elapsed
if rate >= config.drop_rate_threshold then
show_warning("drops", ("Dropping frames: %.1f/s"):format(rate))
elseif state.active_warning == "drops" and rate < config.drop_rate_threshold / 2 then
show_recovery()
end
state.last_drop_count = drops
state.last_drop_check = now
end
local function start_monitoring()
if state.check_timer then
state.check_timer:kill()
end
state.check_timer = mp.add_periodic_timer(config.drop_window_seconds, check_drop_rate)
dbg("monitoring started")
end
local function stop_monitoring()
if state.check_timer then
state.check_timer:kill()
state.check_timer = nil
end
reset_state()
dbg("monitoring stopped")
end
mp.observe_property("path", "string", on_path_change)
mp.observe_property("paused-for-cache", "bool", on_paused_for_cache)
mp.observe_property("cache-buffering-state", "number", on_cache_buffering_state)
mp.observe_property("demuxer-cache-duration", "number", on_cache_duration)
mp.register_event("file-loaded", start_monitoring)
mp.register_event("end-file", stop_monitoring)
mp.register_event("shutdown", stop_monitoring)