diff --git a/src/mpv_control.py b/src/mpv_control.py index e8b89cd..15c2790 100644 --- a/src/mpv_control.py +++ b/src/mpv_control.py @@ -7,13 +7,14 @@ from shutil import get_terminal_size from shlex import quote import mpv import time -import sys import re fzf = FzfPrompt() if get_config('enable_persistent_cache'): - player = mpv.MPV(cache=True, scripts='src/mpv_scripts/mpv_cache.lua:src/mpv_scripts/streamsave.lua') + player = mpv.MPV(cache=True, + scripts='src/mpv_scripts/mpv_cache.lua:src/mpv_scripts/streamsave.lua', + script_opts='streamsave-save_directory=cache,streamsave-dump_mode=continuous,streansave-force_extension=.mkv,streamsave-autostart=no,output_label=overwrite') player.command('script-message', 'streamsave-path', 'cache') else: player = mpv.MPV(cache=True, demuxer_max_bytes=25*1024*1024) diff --git a/src/mpv_scripts/mpv_cache.lua b/src/mpv_scripts/mpv_cache.lua index fa412c2..b280d89 100644 --- a/src/mpv_scripts/mpv_cache.lua +++ b/src/mpv_scripts/mpv_cache.lua @@ -25,18 +25,22 @@ end function make_cache_track(url) + mp.command('script-message streamsave-autostart no') find_uuid = "%x+-%x+-%x+-%x+-%x+" uuid = string.sub(url, string.find(url, find_uuid)) host = get_url_host(url) cache_path_file = 'cache/' .. host .. '/' .. uuid .. '.mkv' + cache_path_named_file = 'cache/' .. host .. '/' .. uuid .. '.mkv' if false == file_exists(cache_path_file) then createDir('cache/' .. host .. '/') msg.verbose('Caching ' .. cache_path_file .. '') mp.command('script-message streamsave-title ' .. uuid .. '') + mp.command('script-message streamsave-force_title ' .. uuid .. '') + mp.command('script-message streamsave-label overwrite') mp.set_property('script-opts/media-uuid', uuid) mp.command('script-message streamsave-extension .mkv') mp.command('script-message streamsave-path cache/' .. host .. '') - mp.command('script-message streamsave-rec') + mp.command('script-message streamsave-autostart yes') else msg.verbose('Already cached ' .. cache_path_file .. '') os.execute('touch ' .. cache_path_file .. '') @@ -52,3 +56,11 @@ mp.add_hook("on_load", 11, function() make_cache_track(url) end end) + +mp.register_event("file-loaded", function() + msg.verbose('reusable cache post-hook activated') + local url = mp.get_property("stream-open-filename", "") + if true == (url:find("https?://") == 1) then + make_cache_track(url) + end +end) diff --git a/src/mpv_scripts/streamsave.lua b/src/mpv_scripts/streamsave.lua index fe69b11..00eab59 100644 --- a/src/mpv_scripts/streamsave.lua +++ b/src/mpv_scripts/streamsave.lua @@ -1,10 +1,9 @@ --[[ streamsave.lua -Version 0.20.6 -2022-10-13 +Version 0.23.2 +2023-5-21 https://github.com/Sagnac/streamsave -NOTE: Modified mpv script aimed at saving live streams and clipping online videos without encoding. @@ -12,10 +11,12 @@ Essentially a wrapper around mpv's cache dumping commands, the script adds the f * Automatic determination of the output file name and format * Option to specify the preferred output directory -* Switch between 3 different dump modes (clip mode, full/continuous dump, write from beginning to current position) +* Switch between 5 different dump modes: + (clip mode, full/continuous dump, write from beginning to current position, current chapter, all chapters) * Prevention of file overwrites * Acceptance of inverted loop ranges, allowing the end point to be set first * Dynamic chapter indicators on the OSC displaying the clipping interval +* Option to track HLS packet drops * Automated stream saving * Workaround for some DAI HLS streams served from .m3u8 where the host changes @@ -51,15 +52,27 @@ and only set the first loop point then press the cache-write keybind. dump_mode=current will dump the cache from timestamp 0 to the current playback position in the file. +dump_mode=chapter will write the current chapter to file. + +dump_mode=segments writes out all chapters to individual files. + +If you wish to output a single chapter using a numerical input instead you can specify it with a command at runtime: +script-message streamsave-chapter 7 + The output_label option allows you to choose how the output filename is tagged. The default uses iterated step increments for every file output; i.e. file-1.mkv, file-2.mkv, etc. -There are 3 other choices: +There are 4 other choices: + output_label=timestamp will append Unix timestamps to the file name. + output_label=range will tag the file with the A-B loop range instead using the format HH.MM.SS e.g. file-[00.15.00 - 00.20.00].mkv + output_label=overwrite will not tag the file and will overwrite any existing files with the same name. +output_label=chapter uses the chapter title for the file name if using one of the chapter modes. + The force_extension option allows you to force a preferred format and sidestep the automatic detection. If using this option it is recommended that a highly flexible container is used (e.g. Matroska). The format is specified as the extension including the dot (e.g. force_extension=.mkv). @@ -78,6 +91,8 @@ Once the A-B points are cleared the original chapters are restored. Any chapters added after A-B mode is entered are added to the initial chapter list. This option is disabled by default; set range_marks=yes in streamsave.conf in order to enable it. +The track_packets option adds chapters to positions where packet loss occurs for HLS streams. + Automation Options: The autostart and autoend options are used for automated stream capturing. @@ -88,6 +103,8 @@ to stop at that time. The hostchange option enables an experimental workaround for DAI HLS .m3u8 streams in which the host changes. If enabled this will result in multiple files being output as the stream reloads. The autostart option must also be enabled in order to autosave these types of streams. +The `on_demand` option is a suboption of the hostchange option which, if enabled, triggers reloads immediately across +segment switches without waiting until playback has reached the end of the last segment. The `quit=HH:MM:SS` option will set a one shot timer from script load to the specified time, at which point the player will exit. This serves as a replacement for autoend when using hostchange. @@ -122,14 +139,16 @@ local unpack = unpack or table.unpack -- change these in streamsave.conf local opts = { save_directory = [[.]], -- output file directory - dump_mode = "continuous", -- - output_label = "overwrite", -- - force_extension = ".mkv", -- extension will be .ext if set + dump_mode = "ab", -- + output_label = "increment", -- + force_extension = "no", -- extension will be .ext if set force_title = "no", -- custom title used for the filename range_marks = false, -- set chapters at A-B loop points? - autostart = true, -- automatically dump cache at start? + track_packets = false, -- track HLS packet drops + autostart = false, -- automatically dump cache at start? autoend = "no", -- cache time to stop at hostchange = false, -- use if the host changes mid stream + on_demand = false, -- hostchange suboption, instant reloads quit = "no", -- quits player at specified time piecewise = false, -- writes stream in parts with autoend } @@ -141,8 +160,12 @@ local file = { title, -- media title inc, -- filename increments ext, -- file extension + loaded, -- flagged once the initial load has taken place pending, -- number of files pending write completion (max 2) queue, -- cache_write queue in case of multiple write requests + writing, -- file writing object returned by the write command + quitsec, -- user specified quit time in seconds + quit_timer, -- player quit timer set according to quitsec oldtitle, -- initialized if title is overridden, allows revert oldext, -- initialized if format is overridden, allows revert oldpath, -- initialized if directory is overriden, allows revert @@ -155,42 +178,49 @@ local loop = { b_revert, -- B loop point prior to keyframe alignment range, -- A-B loop range aligned, -- are the loop points aligned to keyframes? + continuous, -- is the writing continuous? } local cache = { dumped, -- autowrite cache state (serves as an autowrite request) observed, -- whether the cache time is being observed endsec, -- user specified autoend cache time in seconds - prior, -- previous cache time + prior, -- cache duration prior to staging the seamless reload mechanism seekend, -- seekable cache end timestamp part, -- approx. end time of last piece / start time of next piece switch, -- request to observe track switches and seeking use, -- use cache_time instead of seekend for initial piece - restart, -- hostchange interval where subsequent reloads are immediate + id, -- number of times the packet tracking event has fired + packets, -- table of periodic timers indexed by cache id stamps } -local convert_time -local observe_cache -local continuous -local write_file -local reset -local title_change -local container +local track = { + vid, -- video track id + aid, -- audio track id + sid, -- subtitle track id + restart, -- hostchange interval where subsequent reloads are immediate + suspend, -- suspension interval on track-list changes +} + +local segments = {} -- chapter segments set for writing local chapter_list = {} -- initial chapter list local ab_chapters = {} -- A-B loop point chapters -local chapter_points -local get_seekable_cache -local reload -local automatic -local quitseconds -local quit_timer -local autoquit -function convert_time(value) - local i, j, H, M, S = value:find("(%d+):(%d+):(%d+)") - if not i then - return - else +local title_change +local container +local get_chapters +local chapter_points +local reset +local get_seekable_cache +local automatic +local autoquit +local packet_events +local observe_cache +local observe_tracks + +local function convert_time(value) + local H, M, S = value:match("^(%d+):([0-5]%d):([0-5]%d)$") + if H then return H*3600 + M*60 + S end end @@ -199,16 +229,19 @@ local function validate_opts() if opts.output_label ~= "increment" and opts.output_label ~= "range" and opts.output_label ~= "timestamp" and - opts.output_label ~= "overwrite" + opts.output_label ~= "overwrite" and + opts.output_label ~= "chapter" then - msg.warn("Invalid output_label '" .. opts.output_label .. "'") + msg.error("Invalid output_label '" .. opts.output_label .. "'") opts.output_label = "increment" end if opts.dump_mode ~= "ab" and opts.dump_mode ~= "current" and - opts.dump_mode ~= "continuous" + opts.dump_mode ~= "continuous" and + opts.dump_mode ~= "chapter" and + opts.dump_mode ~= "segments" then - msg.warn("Invalid dump_mode '" .. opts.dump_mode .. "'") + msg.error("Invalid dump_mode '" .. opts.dump_mode .. "'") opts.dump_mode = "ab" end if opts.autoend ~= "no" then @@ -216,15 +249,15 @@ local function validate_opts() cache.endsec = convert_time(opts.autoend) end if not convert_time(opts.autoend) then - msg.warn("Invalid autoend value '" .. opts.autoend .. + msg.error("Invalid autoend value '" .. opts.autoend .. "'. Use HH:MM:SS format.") opts.autoend = "no" end end if opts.quit ~= "no" then - quitseconds = convert_time(opts.quit) - if not quitseconds then - msg.warn("Invalid quit value '" .. opts.quit .. + file.quitsec = convert_time(opts.quit) + if not file.quitsec then + msg.error("Invalid quit value '" .. opts.quit .. "'. Use HH:MM:SS format.") opts.quit = "no" end @@ -249,17 +282,22 @@ local function update_opts(changed) if opts.range_marks then chapter_points() else + if not get_chapters() then + mp.set_property_native("chapter-list", chapter_list) + end ab_chapters = {} - mp.set_property_native("chapter-list", chapter_list) end end if changed["autoend"] then cache.endsec = convert_time(opts.autoend) observe_cache() end - if changed["autostart"] or changed["hostchange"] then + if changed["autostart"] then observe_cache() end + if changed["hostchange"] then + observe_tracks(opts.hostchange) + end if changed["quit"] then autoquit() end @@ -268,6 +306,9 @@ local function update_opts(changed) elseif changed["piecewise"] then cache.endsec = convert_time(opts.autoend) end + if changed["track_packets"] then + packet_events(opts.track_packets) + end end options.read_options(opts, "streamsave", update_opts) @@ -281,6 +322,10 @@ local function mode_switch(value) value = "current" elseif opts.dump_mode == "current" then value = "continuous" + elseif opts.dump_mode == "continuous" then + value = "chapter" + elseif opts.dump_mode == "chapter" then + value = "segments" else value = "ab" end @@ -297,8 +342,16 @@ local function mode_switch(value) opts.dump_mode = "current" print("Current position mode") mp.osd_message("Cache write mode: Current position") + elseif value == "chapter" then + opts.dump_mode = "chapter" + print("Chapter mode (single chapter)") + mp.osd_message("Cache write mode: Chapter") + elseif value == "segments" then + opts.dump_mode = "segments" + print("Segments mode (all chapters)") + mp.osd_message("Cache write mode: Segments") else - msg.warn("Invalid dump mode '" .. value .. "'") + msg.error("Invalid dump mode '" .. value .. "'") end end @@ -321,12 +374,13 @@ function container(_, _, req) local file_format = mp.get_property("file-format") if not file_format then reset() + observe_tracks() return end if opts.force_extension ~= "no" and not req then file.ext = opts.force_extension observe_cache() return end - if string.find(file_format, "mp4") + if string.match(file_format, "mp4") or ((video == "h264" or video == "av1" or not video) and (audio == "aac" or not audio)) then @@ -339,6 +393,7 @@ function container(_, _, req) file.ext = ".mkv" end observe_cache() + observe_tracks() file.oldext = nil end @@ -395,6 +450,8 @@ local function label_override(value) value = "timestamp" elseif opts.output_label == "timestamp" then value = "overwrite" + elseif opts.output_label == "overwrite" then + value = "chapter" else value = "increment" end @@ -408,8 +465,10 @@ end local function marks_override(value) if not value or value == "no" then opts.range_marks = false + if not get_chapters() then + mp.set_property_native("chapter-list", chapter_list) + end ab_chapters = {} - mp.set_property_native("chapter-list", chapter_list) print("Range marks disabled") mp.osd_message("streamsave: range marks disabled") elseif value == "yes" then @@ -418,17 +477,12 @@ local function marks_override(value) print("Range marks enabled") mp.osd_message("streamsave: range marks enabled") else - msg.warn("Invalid input '" .. value .. "'. Use yes or no.") + msg.error("Invalid input '" .. value .. "'. Use yes or no.") mp.osd_message("streamsave: invalid input; use yes or no") end end local function autostart_override(value) - if value and value ~= "no" and value ~= "yes" then - msg.warn("Invalid input '" .. value .. "'. Use yes or no.") - mp.osd_message("streamsave: invalid input; use yes or no") - return - end if not value or value == "no" then opts.autostart = false print("Autostart disabled") @@ -437,6 +491,10 @@ local function autostart_override(value) opts.autostart = true print("Autostart enabled") mp.osd_message("streamsave: autostart enabled") + else + msg.error("Invalid input '" .. value .. "'. Use yes or no.") + mp.osd_message("streamsave: invalid input; use yes or no") + return end observe_cache() end @@ -451,24 +509,31 @@ local function autoend_override(value) end local function hostchange_override(value) + local hostchange = opts.hostchange value = value == "cycle" and (not opts.hostchange and "yes" or "no") or value - if value and value ~= "no" and value ~= "yes" then - msg.warn("Invalid input '" .. value .. "'. Use yes or no.") - mp.osd_message("streamsave: invalid input; use yes or no") - return - end if not value or value == "no" then opts.hostchange = false - mp.unobserve_property(reload) - local timer = cache.restart and cache.restart:kill() print("Hostchange disabled") mp.osd_message("streamsave: hostchange disabled") elseif value == "yes" then opts.hostchange = true print("Hostchange enabled") mp.osd_message("streamsave: hostchange enabled") + elseif value == "on_demand" then + opts.on_demand = not opts.on_demand + opts.hostchange = opts.on_demand or opts.hostchange + local status = opts.on_demand and "enabled" or "disabled" + print("Hostchange: On Demand " .. status) + mp.osd_message("streamsave: hostchange on_demand " .. status) + else + local allowed = "yes, no, cycle, or on_demand" + msg.error("Invalid input '" .. value .. "'. Use " .. allowed .. ".") + mp.osd_message("streamsave: invalid input; use " .. allowed) + return + end + if opts.hostchange ~= hostchange then + observe_tracks(opts.hostchange) end - observe_cache() end local function quit_override(value) @@ -491,11 +556,33 @@ local function piecewise_override(value) print("Piecewise dumping enabled") mp.osd_message("streamsave: piecewise dumping enabled") else - msg.warn("Invalid input '" .. value .. "'. Use yes or no.") + msg.error("Invalid input '" .. value .. "'. Use yes or no.") mp.osd_message("streamsave: invalid input; use yes or no") end end +local function packet_override(value) + local track_packets = opts.track_packets + if value == "cycle" then + value = not track_packets and "yes" or "no" + end + if not value or value == "no" then + opts.track_packets = false + print("Track packets disabled") + mp.osd_message("streamsave: track packets disabled") + elseif value == "yes" then + opts.track_packets = true + print("Track packets enabled") + mp.osd_message("streamsave: track packets enabled") + else + msg.error("Invalid input '" .. value .. "'. Use yes or no.") + mp.osd_message("streamsave: invalid input; use yes or no") + end + if opts.track_packets ~= track_packets then + packet_events(opts.track_packets) + end +end + local function range_flip() loop.a = mp.get_property_number("ab-loop-a") loop.b = mp.get_property_number("ab-loop-b") @@ -550,6 +637,47 @@ local function range_stamp(mode) end end +local function write_chapter(chapter) + get_chapters() + if chapter_list[chapter] or chapter == 0 then + segments[1] = { + ["start"] = chapter == 0 and 0 or chapter_list[chapter]["time"], + ["end"] = chapter_list[chapter + 1] + and chapter_list[chapter + 1]["time"] + or mp.get_property_number("duration", "no"), + ["title"] = chapter .. ". " .. (chapter ~= 0 + and chapter_list[chapter]["title"] or file.title) + } + print("Writing chapter " .. chapter .. " ....") + return true + else + msg.error("Chapter not found.") + end +end + +local function extract_segments(n) + for i = 1, n - 1 do + segments[i] = { + ["start"] = chapter_list[i]["time"], + ["end"] = chapter_list[i + 1]["time"], + ["title"] = i .. ". " .. (chapter_list[i]["title"] or file.title) + } + end + if chapter_list[1]["time"] ~= 0 then + table.insert(segments, 1, { + ["start"] = 0, + ["end"] = chapter_list[1]["time"], + ["title"] = "0. " .. file.title + }) + end + table.insert(segments, { + ["start"] = chapter_list[n]["time"], + ["end"] = mp.get_property_number("duration", "no"), + ["title"] = n .. ". " .. (chapter_list[n]["title"] or file.title) + }) + print("Writing out all " .. #segments .. " chapters to separate files ....") +end + local function write_set(mode, file_name, file_pos, quiet) local command = { _flags = { @@ -559,6 +687,11 @@ local function write_set(mode, file_name, file_pos, quiet) } if mode == "ab" then command["name"] = "ab-loop-dump-cache" + elseif (mode == "chapter" or mode == "segments") and segments[1] then + command["name"] = "dump-cache" + command["start"] = segments[1]["start"] + command["end"] = segments[1]["end"] + table.remove(segments, 1) else command["name"] = "dump-cache" command["start"] = 0 @@ -567,18 +700,67 @@ local function write_set(mode, file_name, file_pos, quiet) return command end -local function cache_write(mode, quiet) +local function on_write_finish(cache_write, mode, file_name) + return function(success, _, command_error) + command_error = command_error and msg.error(command_error) + -- check if file is written + if utils.file_info(file_name) then + if success then + print("Finished writing cache to: " .. file_name) + else + msg.warn("Possibly broken file created at: " .. file_name) + end + else + msg.error("File not written.") + end + if loop.continuous and file.pending == 2 then + print("Dumping cache continuously to: " .. file.name) + end + file.pending = file.pending - 1 + -- fulfil any write requests now that the pending queue has been serviced + if next(segments) then + cache_write("segments", true) + elseif mode == "segments" then + mp.osd_message("Cache dumping successfully ended.") + end + if file.queue and next(file.queue) and not segments[1] then + cache_write(unpack(file.queue[1])) + table.remove(file.queue, 1) + end + end +end + +local function cache_write(mode, quiet, chapter) if not (file.title and file.ext) then return end - if file.pending == 2 then + if file.pending == 2 + or segments[1] and file.pending > 0 and not loop.continuous + then file.queue = file.queue or {} -- honor extra write requests when pending queue is full -- but limit number of outstanding write requests to be fulfilled if #file.queue < 10 then - table.insert(file.queue, {mode, quiet}) + table.insert(file.queue, {mode, quiet, chapter}) end return end range_flip() + -- set the output list for the chapter modes + if mode == "segments" and not segments[1] then + get_chapters() + local n = #chapter_list + if n > 0 then + extract_segments(n) + quiet = true + mp.osd_message("Cache dumping started.") + else + mode = "continuous" + end + elseif mode == "chapter" and not segments[1] then + chapter = chapter or mp.get_property_number("chapter", -1) + 1 + if not write_chapter(chapter) then + return + end + end -- evaluate tagging conditions and set file name if opts.output_label == "increment" then increment_filename() @@ -588,42 +770,27 @@ local function cache_write(mode, quiet) file.name = set_name(-os.time()) elseif opts.output_label == "overwrite" then file.name = set_name("") + elseif opts.output_label == "chapter" then + if segments[1] then + file.name = file.path .. "/" .. segments[1]["title"] .. file.ext + else + increment_filename() + end end -- dump cache according to mode local file_pos - local file_name = file.name -- scope reduction so callback verifies correct file file.pending = (file.pending or 0) + 1 - continuous = mode == "continuous" or loop.a and not loop.b + loop.continuous = mode == "continuous" + or mode == "ab" and loop.a and not loop.b + or segments[1] and segments[1]["end"] == "no" if mode == "current" then file_pos = mp.get_property_number("playback-time", 0) - elseif continuous and file.pending == 1 then - print("Dumping cache continuously to:" .. file_name) + elseif loop.continuous and file.pending == 1 then + print("Dumping cache continuously to: " .. file.name) end - write_file = mp.command_native_async ( - write_set(mode, file_name, file_pos, quiet), - function(success, _, command_error) - command_error = command_error and msg.error(command_error) - -- check if file is written - if utils.file_info(file_name) then - if success then - print("Finished writing cache to:" .. file_name) - else - msg.warn("Possibly broken file created at:" .. file_name) - end - else - msg.error("File not written.") - end - if continuous and file.pending == 2 then - print("Dumping cache continuously to:" .. file.name) - end - file.pending = file.pending - 1 - -- fulfil any write requests now that the pending queue has been serviced - if file.queue and #file.queue > 0 then - cache_write(file.queue[1][1], file.queue[1][2]) - table.remove(file.queue, 1) - end - end - ) + local commands = write_set(mode, file.name, file_pos, quiet) + local callback = on_write_finish(cache_write, mode, file.name) + file.writing = mp.command_native_async(commands, callback) return true end @@ -649,16 +816,15 @@ local function align_cache() end end --- creates chapters at A-B loop points -function chapter_points() - if not opts.range_marks then - return end +function get_chapters() local current_chapters = mp.get_property_native("chapter-list", {}) + local updated -- do the stored chapters reflect the current chapters ? -- make sure master list is up to date - if current_chapters[1] and + if not current_chapters[1] or not string.match(current_chapters[1]["title"], "^[AB] loop point$") then chapter_list = current_chapters + updated = true -- if a script has added chapters after A-B points are set then -- add those to the original chapter list elseif #current_chapters > #ab_chapters then @@ -666,12 +832,22 @@ function chapter_points() table.insert(chapter_list, current_chapters[i]) end end + return updated +end + +-- creates chapters at A-B loop points +function chapter_points() + if not opts.range_marks then + return end + local updated = get_chapters() ab_chapters = {} -- restore original chapter list if A-B points are cleared -- otherwise set chapters to A-B points range_flip() if not loop.a and not loop.b then - mp.set_property_native("chapter-list", chapter_list) + if not updated then + mp.set_property_native("chapter-list", chapter_list) + end else if loop.a then ab_chapters[1] = { @@ -696,36 +872,53 @@ end -- stops writing the file local function stop() - mp.abort_async_command(write_file or {}) + mp.abort_async_command(file.writing or {}) end function reset() if cache.observed or cache.dumped then stop() mp.unobserve_property(automatic) - mp.unobserve_property(reload) mp.unobserve_property(get_seekable_cache) cache.endsec = convert_time(opts.autoend) cache.observed = false end - cache.prior = 0 cache.part = 0 cache.dumped = false cache.switch = true end reset() -function get_seekable_cache(prop, range_check, underrun) +-- reload on demand (hostchange) +local function reload() + reset() + observe_tracks() + msg.warn("Reloading stream due to host change.") + mp.command("playlist-play-index current") +end + +local function stabilize() + if mp.get_property_number("demuxer-cache-time", 0) > 1500 then + reload() + end +end + +local function suspend() + if not track.suspend then + track.suspend = mp.add_timeout(25, stabilize) + else + track.suspend:resume() + end +end + +function get_seekable_cache(prop, range_check) -- use the seekable part of the cache for more accurate timestamps local cache_state = mp.get_property_native("demuxer-cache-state", {}) - if underrun then - return cache_state["underrun"] - end local seekable_ranges = cache_state["seekable-ranges"] or {} if prop then if range_check ~= false and (#seekable_ranges == 0 - or not mp.get_property_number("demuxer-cache-time")) + or not cache_state["cache-end"]) then reset() cache.use = opts.piecewise @@ -737,45 +930,67 @@ function get_seekable_cache(prop, range_check, underrun) for i, range in ipairs(seekable_ranges) do seekable_ends[i] = range["end"] or 0 end - cache.seekend = math.max(0, unpack(seekable_ends)) - return cache.seekend + return math.max(0, unpack(seekable_ends)) end -function reload(_, play_time) - local cache_duration = mp.get_property_number("demuxer-cache-duration") - if play_time and play_time >= cache.seekend - 0.25 - or cache_duration and math.abs(cache.prior - cache_duration) > 4800 - or get_seekable_cache(nil, false, true) +-- seamlessly reload on inserts (hostchange) +local function seamless(_, cache_state) + cache_state = cache_state or {} + local reader = math.abs(cache_state["reader-pts"] or 0) + local cache_duration = math.abs(cache_state["cache-duration"] or cache.prior) + -- wait until playback of the loaded cache has practically ended + -- or there's a timestamp reset / position shift + if reader >= cache.seekend - 0.25 + or cache.prior - cache_duration > 3000 + or cache_state["underrun"] then + reload() + track.restart = track.restart or mp.add_timeout(300, function() end) + track.restart:resume() + end +end + +-- detect stream switches (hostchange) +local function detect() + local eq = true + local t = { + vid = mp.get_property_number("current-tracks/video/id", 0), + aid = mp.get_property_number("current-tracks/audio/id", 0), + sid = mp.get_property_number("current-tracks/sub/id", 0) + } + for k, v in pairs(t) do + eq = track[k] == v and eq + track[k] = v + end + -- do not initiate a reload process if the track ids do not match + -- or the track loading suspension interval is active + if not eq then + return + end + if track.suspend:is_enabled() then + stabilize() + return + end + -- bifurcate + if track.restart and track.restart:is_enabled() then + track.restart:kill() + reload() + elseif opts.on_demand then + reload() + else + -- watch the cache state outside of the interval + -- and use it to decide when to reload reset() - cache.restart = cache.restart or mp.add_timeout(300, function() end) - cache.restart:resume() - msg.warn("Reloading stream due to host change.") - mp.command("playlist-play-index current") + observe_tracks(false) + cache.observed = true + cache.prior = math.abs(mp.get_property_number("demuxer-cache-duration", 4E3)) + cache.seekend = get_seekable_cache() + mp.observe_property("demuxer-cache-state", "native", seamless) end end function automatic(_, cache_time) - if opts.hostchange and cache.prior ~= 0 - and (not cache_time or math.abs(cache_time - cache.prior) > 300 - or mp.get_property_number("demuxer-cache-duration", 0) > 11000) - and not mp.get_property_bool("seeking") - then - if not cache.restart or not cache.restart:is_enabled() then - reset() - cache.observed = true - cache.prior = mp.get_property_number("demuxer-cache-duration", 0) - get_seekable_cache() - mp.observe_property("playback-time", "number", reload) - else - -- reload stream - cache.restart:kill() - reset() - msg.warn("Reloading stream due to host change.") - mp.command("playlist-play-index current") - end - return - elseif not cache_time then + if not cache_time then reset() cache.use = opts.piecewise observe_cache() @@ -805,12 +1020,9 @@ function automatic(_, cache_time) mp.observe_property("seeking", "bool", get_seekable_cache) end -- unobserve cache time if not needed - if cache.dumped and not cache.switch - and not cache.endsec and not opts.hostchange - then + if cache.dumped and not cache.switch and not cache.endsec then mp.unobserve_property(automatic) cache.observed = false - cache.prior = 0 return end -- stop cache dump @@ -830,34 +1042,104 @@ function automatic(_, cache_time) end stop() end - cache.prior = cache_time end function autoquit() if opts.quit == "no" then - if quit_timer then - quit_timer:kill() + if file.quit_timer then + file.quit_timer:kill() end - elseif not quit_timer then - quit_timer = mp.add_timeout(quitseconds, + elseif not file.quit_timer then + file.quit_timer = mp.add_timeout(file.quitsec, function() stop() mp.command("quit") print("Quit after " .. opts.quit) end) else - quit_timer["timeout"] = quitseconds - quit_timer:kill() - quit_timer:resume() + file.quit_timer["timeout"] = file.quitsec + file.quit_timer:kill() + file.quit_timer:resume() end end autoquit() +local function fragment_chapters(packets, cache_time, stamp) + local no_loop_chapters = get_chapters() + local title = string.format("%s segment(s) dropped [%s]", packets, stamp) + for _, chapter in ipairs(chapter_list) do + if chapter["title"] == title then + cache.packets[stamp]:kill() + cache.packets[stamp] = nil + return + end + end + table.insert(chapter_list, { + title = title, + time = cache_time + }) + if no_loop_chapters then + mp.set_property_native("chapter-list", chapter_list) + end +end + +local function packet_handler(t) + if not opts.track_packets then -- second layer in case unregistering is async + return + end + if t.prefix == "ffmpeg/demuxer" then + local packets = t.text:match("^hls: skipping (%d+)") + if packets then + local cache_time = mp.get_property_number("demuxer-cache-time") + if cache_time then + -- ensure the chapters set + cache.id = cache.id + 1 + local stamp = string.format("%#x", cache.id) + cache.packets[stamp] = mp.add_periodic_timer(3, + function() + fragment_chapters(packets, cache_time, stamp) + end + ) + end + end + end +end + +function packet_events(state) + if not state then + mp.unregister_event(packet_handler) + for _, timer in pairs(cache.packets) do + timer:kill() + end + cache.id = nil + cache.packets = nil + local no_loop_chapters = get_chapters() + local n = #chapter_list + for i = n, 1, -1 do + if chapter_list[i]["title"]:match("%d+ segment%(s%) dropped") then + table.remove(chapter_list, i) + end + end + if no_loop_chapters and n > #chapter_list then + mp.set_property_native("chapter-list", chapter_list) + end + else + cache.id = 0 + cache.packets = {} + mp.enable_messages("warn") + mp.register_event("log-message", packet_handler) + end +end +if opts.track_packets then + packet_events(true) +end + -- cache time observation switch for runtime changes function observe_cache() local network = mp.get_property_bool("demuxer-via-network") - local obs_xyz = opts.autostart or cache.endsec or opts.hostchange + local obs_xyz = opts.autostart or cache.endsec if not cache.observed and obs_xyz and network then + cache.dumped = (file.pending or 0) ~= 0 mp.observe_property("demuxer-cache-time", "number", automatic) cache.observed = true elseif (cache.observed or cache.dumped) and (not obs_xyz or not network) then @@ -865,7 +1147,30 @@ function observe_cache() end end -mp.observe_property("media-uuid", "string", title_change) +-- track-list observation switch for runtime changes +function observe_tracks(state) + if state then + suspend() + mp.observe_property("track-list", "native", detect) + elseif state == false then + mp.unobserve_property(detect) + mp.unobserve_property(seamless) + cache.prior = nil + local timer = track.restart and track.restart:kill() + -- reset the state on manual reloads + elseif cache.prior then + observe_tracks(false) + observe_tracks(true) + elseif opts.hostchange then + suspend() + end +end + +if opts.hostchange then + observe_tracks(true) +end + +mp.observe_property("media-title", "string", title_change) --[[ video and audio formats observed in order to handle track changes useful if e.g. --script-opts=ytdl_hook-all_formats=yes @@ -878,13 +1183,16 @@ mp.observe_property("file-format", "string", container) an external file, so make sure existing chapters are not overwritten by observing A-B loop changes only after the file is loaded. ]] local function on_file_load() - mp.observe_property("ab-loop-a", "native", chapter_points) - mp.observe_property("ab-loop-b", "native", chapter_points) + if file.loaded then + chapter_points() + else + mp.observe_property("ab-loop-a", "native", chapter_points) + mp.observe_property("ab-loop-b", "native", chapter_points) + file.loaded = true + end end mp.register_event("file-loaded", on_file_load) -mp.register_script_message("streamsave-rec", function() cache_write(opts.dump_mode) - end) mp.register_script_message("streamsave-mode", mode_switch) mp.register_script_message("streamsave-title", title_override) mp.register_script_message("streamsave-extension", format_override) @@ -896,6 +1204,12 @@ mp.register_script_message("streamsave-autoend", autoend_override) mp.register_script_message("streamsave-hostchange", hostchange_override) mp.register_script_message("streamsave-quit", quit_override) mp.register_script_message("streamsave-piecewise", piecewise_override) +mp.register_script_message("streamsave-packets", packet_override) +mp.register_script_message("streamsave-chapter", + function(chapter) + cache_write("chapter", _, tonumber(chapter)) + end +) mp.add_key_binding("Alt+z", "mode-switch", function() mode_switch("cycle") end) mp.add_key_binding("Ctrl+x", "stop-cache-write", stop)