-- reload.lua
--
-- When an online video is stuck buffering or got very slow CDN
-- source, restarting often helps. This script provides automatic
-- reloading of videos that doesn't have buffering progress for some
-- time while keeping the current time position. It also adds `Ctrl+r`
-- keybinding to reload video manually.
--
-- SETTINGS
--
-- To override default setting put the `lua-settings/reload.conf` file in
-- mpv user folder, on linux it is `~/.config/mpv`.  NOTE: config file
-- name should match the name of the script.
--
-- Default `reload.conf` settings:
--
-- ```
-- # enable automatic reload on timeout
-- # when paused-for-cache event fired, we will wait
-- # paused_for_cache_timer_timeout sedonds and then reload the video
-- paused_for_cache_timer_enabled=yes
--
-- # checking paused_for_cache property interval in seconds,
-- # can not be less than 0.05 (50 ms)
-- paused_for_cache_timer_interval=1
--
-- # time in seconds to wait until reload
-- paused_for_cache_timer_timeout=10
--
-- # enable automatic reload based on demuxer cache
-- # if demuxer-cache-time property didn't change in demuxer_cache_timer_timeout
-- # time interval, the video will be reloaded as soon as demuxer cache depleated
-- demuxer_cache_timer_enabled=yes
--
-- # checking demuxer-cache-time property interval in seconds,
-- # can not be less than 0.05 (50 ms)
-- demuxer_cache_timer_interval=2
--
-- # if demuxer cache didn't receive any data during demuxer_cache_timer_timeout
-- # we decide that it has no progress and will reload the stream when
-- # paused_for_cache event happens
-- demuxer_cache_timer_timeout=20
--
-- # when the end-of-file is reached, reload the stream to check
-- # if there is more content available.
-- reload_eof_enabled=no
--
-- # keybinding to reload stream from current time position
-- # you can disable keybinding by setting it to empty value
-- # reload_key_binding=
-- reload_key_binding=Ctrl+r
-- ```
--
-- DEBUGGING
--
-- Debug messages will be printed to stdout with mpv command line option
-- `--msg-level='reload=debug'`. You may also need to add the `--no-msg-color`
-- option to make the debug logs visible if you are using a dark colorscheme
-- in terminal.
local msg = require 'mp.msg'
local options = require 'mp.options'
local utils = require 'mp.utils'

local settings = {
    paused_for_cache_timer_enabled = true,
    paused_for_cache_timer_interval = 1,
    paused_for_cache_timer_timeout = 10,
    demuxer_cache_timer_enabled = true,
    demuxer_cache_timer_interval = 2,
    demuxer_cache_timer_timeout = 20,
    reload_eof_enabled = false,
    reload_key_binding = "Ctrl+r"
}

-- global state stores properties between reloads
local property_path = nil
local property_time_pos = 0
local property_keep_open = nil

-- FSM managing the demuxer cache.
--
-- States:
--
-- * fetch - fetching new data
-- * stale - unable to fetch new data for time <  'demuxer_cache_timer_timeout'
-- * stuck - unable to fetch new data for time >= 'demuxer_cache_timer_timeout'
--
-- State transitions:
--
--    +---------------------------+
--    v                           |
-- +-------+     +-------+     +-------+
-- + fetch +<--->+ stale +---->+ stuck |
-- +-------+     +-------+     +-------+
--   |   ^         |   ^         |   ^
--   +---+         +---+         +---+
local demuxer_cache = {
    timer = nil,

    state = { name = 'uninitialized', demuxer_cache_time = 0, in_state_time = 0 },

    events = {
        continue_fetch = { name = 'continue_fetch', from = 'fetch', to = 'fetch' },
        continue_stale = { name = 'continue_stale', from = 'stale', to = 'stale' },
        continue_stuck = { name = 'continue_stuck', from = 'stuck', to = 'stuck' },
        fetch_to_stale = { name = 'fetch_to_stale', from = 'fetch', to = 'stale' },
        stale_to_fetch = { name = 'stale_to_fetch', from = 'stale', to = 'fetch' },
        stale_to_stuck = { name = 'stale_to_stuck', from = 'stale', to = 'stuck' },
        stuck_to_fetch = { name = 'stuck_to_fetch', from = 'stuck', to = 'fetch' }
    }

}

-- Always start with 'fetch' state
function demuxer_cache.reset_state()
    demuxer_cache.state = {
        name = demuxer_cache.events.continue_fetch.to,
        demuxer_cache_time = 0,
        in_state_time = 0
    }
end

-- Has 'demuxer_cache_time' changed
function demuxer_cache.has_progress_since(t)
    return demuxer_cache.state.demuxer_cache_time ~= t
end

function demuxer_cache.is_state_fetch()
    return demuxer_cache.state.name == demuxer_cache.events.continue_fetch.to
end

function demuxer_cache.is_state_stale()
    return demuxer_cache.state.name == demuxer_cache.events.continue_stale.to
end

function demuxer_cache.is_state_stuck()
    return demuxer_cache.state.name == demuxer_cache.events.continue_stuck.to
end

function demuxer_cache.transition(event)
    if demuxer_cache.state.name == event.from then
        -- state setup
        demuxer_cache.state.demuxer_cache_time = event.demuxer_cache_time

        if event.name == 'continue_fetch' then
            demuxer_cache.state.in_state_time = demuxer_cache.state
                .in_state_time +
                event.interval
        elseif event.name == 'continue_stale' then
            demuxer_cache.state.in_state_time = demuxer_cache.state
                .in_state_time +
                event.interval
        elseif event.name == 'continue_stuck' then
            demuxer_cache.state.in_state_time = demuxer_cache.state
                .in_state_time +
                event.interval
        elseif event.name == 'fetch_to_stale' then
            demuxer_cache.state.in_state_time = 0
        elseif event.name == 'stale_to_fetch' then
            demuxer_cache.state.in_state_time = 0
        elseif event.name == 'stale_to_stuck' then
            demuxer_cache.state.in_state_time = 0
        elseif event.name == 'stuck_to_fetch' then
            demuxer_cache.state.in_state_time = 0
        end

        -- state transition
        demuxer_cache.state.name = event.to

        msg.debug('demuxer_cache.transition', event.name,
            utils.to_string(demuxer_cache.state))
    else
        msg.error('demuxer_cache.transition', 'illegal transition', event.name,
            'from state', demuxer_cache.state.name)
    end
end

function demuxer_cache.initialize(demuxer_cache_timer_interval)
    demuxer_cache.reset_state()
    demuxer_cache.timer = mp.add_periodic_timer(demuxer_cache_timer_interval,
        function()
            demuxer_cache.demuxer_cache_timer_tick(
                mp.get_property_native('demuxer-cache-time'),
                demuxer_cache_timer_interval)
        end)
end

-- If there is no progress of demuxer_cache_time in
-- settings.demuxer_cache_timer_timeout time interval switch state to
-- 'stuck' and switch back to 'fetch' as soon as any progress is made
function demuxer_cache.demuxer_cache_timer_tick(demuxer_cache_time,
                                                demuxer_cache_timer_interval)
    local event = nil
    local cache_has_progress = demuxer_cache.has_progress_since(
        demuxer_cache_time)

    -- I miss pattern matching so much
    if demuxer_cache.is_state_fetch() then
        if cache_has_progress then
            event = demuxer_cache.events.continue_fetch
        else
            event = demuxer_cache.events.fetch_to_stale
        end
    elseif demuxer_cache.is_state_stale() then
        if cache_has_progress then
            event = demuxer_cache.events.stale_to_fetch
        elseif demuxer_cache.state.in_state_time <
            settings.demuxer_cache_timer_timeout then
            event = demuxer_cache.events.continue_stale
        else
            event = demuxer_cache.events.stale_to_stuck
        end
    elseif demuxer_cache.is_state_stuck() then
        if cache_has_progress then
            event = demuxer_cache.events.stuck_to_fetch
        else
            event = demuxer_cache.events.continue_stuck
        end
    end

    event.demuxer_cache_time = demuxer_cache_time
    event.interval = demuxer_cache_timer_interval
    demuxer_cache.transition(event)
end

local paused_for_cache = { timer = nil, time = 0 }

function paused_for_cache.reset_timer()
    msg.debug('paused_for_cache.reset_timer', paused_for_cache.time)
    if paused_for_cache.timer then
        paused_for_cache.timer:kill()
        paused_for_cache.timer = nil
        paused_for_cache.time = 0
    end
end

function paused_for_cache.start_timer(interval_seconds, timeout_seconds)
    msg.debug('paused_for_cache.start_timer', paused_for_cache.time)
    if not paused_for_cache.timer then
        paused_for_cache.timer = mp.add_periodic_timer(interval_seconds,
            function()
                paused_for_cache.time = paused_for_cache.time + interval_seconds
                if paused_for_cache.time >= timeout_seconds then
                    paused_for_cache.reset_timer()
                    reload_resume()
                end
                msg.debug('paused_for_cache', 'tick', paused_for_cache.time)
            end)
    end
end

function paused_for_cache.handler(property, is_paused)
    if is_paused then
        if demuxer_cache.is_state_stuck() then
            msg.info("demuxer cache has no progress")
            -- reset demuxer state to avoid immediate reload if
            -- paused_for_cache event triggered right after reload
            demuxer_cache.reset_state()
            reload_resume()
        end

        paused_for_cache.start_timer(settings.paused_for_cache_timer_interval,
            settings.paused_for_cache_timer_timeout)
    else
        paused_for_cache.reset_timer()
    end
end

function read_settings()
    options.read_options(settings, mp.get_script_name())
    msg.debug(utils.to_string(settings))
end

function reload(path, time_pos)
    msg.debug("reload", path, time_pos)
    if time_pos == nil then
        mp.commandv("loadfile", path, "replace")
    else
        local success = mp.commandv("loadfile", path, "replace", -1,
            "start=+" .. time_pos)
        -- fallback to old syntax of loadfile for compatibility
        if success == nil then
            msg.warn(
                "old loadfile syntax detected. falling back to using old syntax. update mpv to remove this warning")
            mp.commandv("loadfile", path, "replace", "start=+" .. time_pos)
        end
    end
end

function reload_resume()
    local path = mp.get_property("path", property_path)
    local time_pos = mp.get_property("time-pos")
    local reload_duration = mp.get_property_native("duration")

    local playlist_count = mp.get_property_number("playlist/count")
    local playlist_pos = mp.get_property_number("playlist-pos")
    local playlist = {}
    for i = 0, playlist_count - 1 do
        playlist[i] = mp.get_property("playlist/" .. i .. "/filename")
    end
    -- Tries to determine live stream vs. pre-recordered VOD. VOD has non-zero
    -- duration property. When reloading VOD, to keep the current time position
    -- we should provide offset from the start. Stream doesn't have fixed start.
    -- Decent choice would be to reload stream from it's current 'live' positon.
    -- That's the reason we don't pass the offset when reloading streams.
    if reload_duration and reload_duration > 0 then
        msg.info("reloading video from", time_pos, "second")
        reload(path, time_pos)
        -- VODs get stuck when reload is called without a time_pos
        -- this is most noticeable in youtube videos whenever download gets stuck in the first frames
        -- video would stay paused without being actually paused
        -- issue surfaced in mpv 0.33, afaik
    elseif reload_duration and reload_duration == 0 then
        msg.info("reloading video from", time_pos, "second")
        reload(path, time_pos)
    else
        msg.info("reloading stream")
        reload(path, nil)
    end
    msg.info("file", playlist_pos + 1, "of", playlist_count, "in playlist")
    for i = 0, playlist_pos - 1 do
        mp.commandv("loadfile", playlist[i], "append")
    end
    mp.commandv("playlist-move", 0, playlist_pos + 1)
    for i = playlist_pos + 1, playlist_count - 1 do
        mp.commandv("loadfile", playlist[i], "append")
    end
end

function reload_eof(property, eof_reached)
    msg.debug("reload_eof", property, eof_reached)
    local time_pos = mp.get_property_number("time-pos")
    local duration = mp.get_property_number("duration")

    if eof_reached and round(time_pos) == round(duration) then
        msg.debug("property_time_pos", property_time_pos, "time_pos", time_pos)

        -- Check that playback time_pos made progress after the last reload. When
        -- eof is reached we try to reload the video, in case there is more content
        -- available. If time_pos stayed the same after reload, it means that the
        -- video length stayed the same, and we can end the playback.
        if round(property_time_pos) == round(time_pos) then
            msg.info("eof reached, playback ended")
            mp.set_property("keep-open", property_keep_open)
        else
            msg.info("eof reached, checking if more content available")
            reload_resume()
            mp.set_property_bool("pause", false)
            property_time_pos = time_pos
        end
    end
end

function on_file_loaded(event)
    local debug_info = {
        event = event,
        time_pos = mp.get_property("time-pos"),
        stream_pos = mp.get_property("stream-pos"),
        stream_end = mp.get_property("stream-end"),
        duration = mp.get_property("duration"),
        seekable = mp.get_property("seekable"),
        pause = mp.get_property("pause"),
        paused_for_cache = mp.get_property("paused-for-cache"),
        cache_buffering_state = mp.get_property("cache-buffering-state")
    }
    msg.debug("debug_info", utils.to_string(debug_info))

    -- When the video is reloaded after being paused for cache, it won't start
    -- playing again while all properties looks fine:
    -- `pause=no`, `paused-for-cache=no` and `cache-buffering-state=100`.
    -- As a workaround, we cycle through the paused state by sending two SPACE
    -- keypresses.
    -- What didn't work:
    -- - Cycling through the `pause` property.
    -- - Run the `playlist-play-index current` command.
    mp.commandv("keypress", 'SPACE')
    mp.commandv("keypress", 'SPACE')
end

-- Round positive numbers.
function round(num) return math.floor(num + 0.5) end

-- main

read_settings()

if settings.reload_key_binding ~= "" then
    mp.add_key_binding(settings.reload_key_binding, "reload_resume",
        reload_resume)
end

if settings.paused_for_cache_timer_enabled then
    mp.observe_property("paused-for-cache", "bool", paused_for_cache.handler)
end

if settings.demuxer_cache_timer_enabled then
    demuxer_cache.initialize(settings.demuxer_cache_timer_interval)
end

if settings.reload_eof_enabled then
    -- vo-configured == video output created && its configuration went ok
    mp.observe_property("vo-configured", "bool", function(name, vo_configured)
        msg.debug(name, vo_configured)
        if vo_configured then
            property_path = mp.get_property("path")
            property_keep_open = mp.get_property("keep-open")
            mp.set_property("keep-open", "yes")
            mp.set_property("keep-open-pause", "no")
        end
    end)

    mp.observe_property("eof-reached", "bool", reload_eof)
end

mp.register_event("file-loaded", on_file_loaded)

