funkwlmpv/src/mpv_scripts/streamsave.lua

1220 lines
43 KiB
Lua

--[[
streamsave.lua
Version 0.23.2
2023-5-21
https://github.com/Sagnac/streamsave
mpv script aimed at saving live streams and clipping online videos without encoding.
Essentially a wrapper around mpv's cache dumping commands, the script adds the following functionality:
* Automatic determination of the output file name and format
* Option to specify the preferred output directory
* 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
By default the A-B loop points (set using the `l` key in mpv) determine the portion of the cache written to disk.
It is advisable that you set --demuxer-max-bytes and --demuxer-max-back-bytes to larger values
(e.g. at least 1GiB) in order to have a larger cache.
If you want to use with local files set cache=yes in mpv.conf
Options are specified in ~~/script-opts/streamsave.conf
Runtime changes to all user options are supported via the `script-opts` property by using mpv's `set` or
`change-list` input commands and the `streamsave-` prefix.
General Options:
save_directory sets the output file directory. Don't use quote marks or a trailing slash when specifying paths here.
Example: save_directory=C:\User Directory
mpv double tilde paths ~~/ and home path shortcuts ~/ are also accepted.
By default files are dumped in the current directory.
dump_mode=continuous will use dump-cache, setting the initial timestamp to 0 and leaving the end timestamp unset.
Use this mode if you want to dump the entire cache.
This process will continue as packets are read and until the streams change, the player is closed,
or the user presses the stop keybind.
Under this mode pressing the cache-write keybind again will stop writing the first file and
initiate another file starting at 0 and continuing as the cache increases.
If you want continuous dumping with a different starting point use the default A-B mode instead
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 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).
If this option is set, `script-message streamsave-extension revert` will run the automatic determination at runtime;
running this command again will reset the extension to what's specified in force_extension.
The force_title option will set the title used for the filename. By default the script uses the media-title.
This is specified without double quote marks in streamsave.conf, e.g. force_title=Example Title
The output_label is still used here and file overwrites are prevented if desired.
Changing the filename title to the media-title is still possible at runtime by using the revert argument,
as in the force_extension example.
The range_marks option allows the script to set temporary chapters at A-B loop points.
If chapters already exist they are stored and cleared whenever any A-B points are set.
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.
Set autostart=yes if you want the script to trigger cache writing immediately on stream load.
Set autoend to a time format of the form HH:MM:SS (e.g. autoend=01:20:08) if you want the file writing
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.
Running `script-message streamsave-quit HH:MM:SS` at runtime will reset and restart the timer.
Set piecewise=yes if you want to save a stream in parts automatically, useful for
e.g. saving long streams on slow systems. Set autoend to the duration preferred for each output file.
This feature requires autostart=yes.
mpv's script-message command can be used at runtime to set the dump mode, override the output title
or file extension, change the save directory, or switch the output label.
If you override the title, the file extension, or the directory, the revert argument can be used
to set it back to the default value.
Examples:
script-message streamsave-mode continuous
script-message streamsave-title "Example Title"
script-message streamsave-extension .mkv
script-message streamsave-extension revert
script-message streamsave-path ~/streams
script-message streamsave-label range
]]
local options = require 'mp.options'
local utils = require 'mp.utils'
local msg = require 'mp.msg'
local unpack = unpack or table.unpack
-- default user options
-- change these in streamsave.conf
local opts = {
save_directory = [[.]], -- output file directory
dump_mode = "ab", -- <ab|current|continuous|chapter|segments>
output_label = "increment", -- <increment|range|timestamp|overwrite|chapter>
force_extension = "no", -- <no|.ext> extension will be .ext if set
force_title = "no", -- <no|title> custom title used for the filename
range_marks = false, -- <yes|no> set chapters at A-B loop points?
track_packets = false, -- <yes|no> track HLS packet drops
autostart = false, -- <yes|no> automatically dump cache at start?
autoend = "no", -- <no|HH:MM:SS> cache time to stop at
hostchange = false, -- <yes|no> use if the host changes mid stream
on_demand = false, -- <yes|no> hostchange suboption, instant reloads
quit = "no", -- <no|HH:MM:SS> quits player at specified time
piecewise = false, -- <yes|no> writes stream in parts with autoend
}
-- for internal use
local file = {
name, -- file name (full path to file)
path, -- directory the file is written to
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
}
local loop = {
a, -- A loop point as number type
b, -- B loop point as number type
a_revert, -- A loop point prior to keyframe alignment
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, -- 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
id, -- number of times the packet tracking event has fired
packets, -- table of periodic timers indexed by cache id stamps
}
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 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
local function validate_opts()
if opts.output_label ~= "increment" and
opts.output_label ~= "range" and
opts.output_label ~= "timestamp" and
opts.output_label ~= "overwrite" and
opts.output_label ~= "chapter"
then
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" and
opts.dump_mode ~= "chapter" and
opts.dump_mode ~= "segments"
then
msg.error("Invalid dump_mode '" .. opts.dump_mode .. "'")
opts.dump_mode = "ab"
end
if opts.autoend ~= "no" then
if not cache.part then
cache.endsec = convert_time(opts.autoend)
end
if not convert_time(opts.autoend) then
msg.error("Invalid autoend value '" .. opts.autoend ..
"'. Use HH:MM:SS format.")
opts.autoend = "no"
end
end
if opts.quit ~= "no" then
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
end
end
local function update_opts(changed)
validate_opts()
-- expand mpv meta paths (e.g. ~~/directory)
file.path = mp.command_native({"expand-path", opts.save_directory})
if opts.force_title ~= "no" then
file.title = opts.force_title
elseif changed["force_title"] then
title_change(_, mp.get_property("media-title"), true)
end
if opts.force_extension ~= "no" then
file.ext = opts.force_extension
elseif changed["force_extension"] then
container(_, _, true)
end
if changed["range_marks"] then
if opts.range_marks then
chapter_points()
else
if not get_chapters() then
mp.set_property_native("chapter-list", chapter_list)
end
ab_chapters = {}
end
end
if changed["autoend"] then
cache.endsec = convert_time(opts.autoend)
observe_cache()
end
if changed["autostart"] then
observe_cache()
end
if changed["hostchange"] then
observe_tracks(opts.hostchange)
end
if changed["quit"] then
autoquit()
end
if changed["piecewise"] and not opts.piecewise then
cache.part = 0
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)
update_opts{}
-- dump mode switching
local function mode_switch(value)
value = value or opts.dump_mode
if value == "cycle" then
if opts.dump_mode == "ab" then
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
end
if value == "continuous" then
opts.dump_mode = "continuous"
print("Continuous mode")
mp.osd_message("Cache write mode: Continuous")
elseif value == "ab" then
opts.dump_mode = "ab"
print("A-B loop mode")
mp.osd_message("Cache write mode: A-B loop")
elseif value == "current" then
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.error("Invalid dump mode '" .. value .. "'")
end
end
-- Set the principal part of the file name using the media title
function title_change(_, media_title, req)
if opts.force_title ~= "no" and not req then
file.title = opts.force_title
return end
if media_title then
-- Replacement of reserved file name characters on Windows
file.title = media_title:gsub("[\\/:*?\"<>|]", ".")
file.oldtitle = nil
end
end
-- Determine container for standard formats
function container(_, _, req)
local audio = mp.get_property("audio-codec-name")
local video = mp.get_property("video-format")
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.match(file_format, "mp4")
or ((video == "h264" or video == "av1" or not video) and
(audio == "aac" or not audio))
then
file.ext = ".mp4"
elseif (video == "vp8" or video == "vp9" or not video)
and (audio == "opus" or audio == "vorbis" or not audio)
then
file.ext = ".webm"
else
file.ext = ".mkv"
end
observe_cache()
observe_tracks()
file.oldext = nil
end
local function format_override(ext)
ext = ext or file.ext
file.oldext = file.oldext or file.ext
if ext == "revert" and file.ext == opts.force_extension then
container(_, _, true)
elseif ext == "revert" and opts.force_extension ~= "no" then
file.ext = opts.force_extension
elseif ext == "revert" then
file.ext = file.oldext
else
file.ext = ext
end
print("file extension changed to " .. file.ext)
mp.osd_message("streamsave: file extension changed to " .. file.ext)
end
local function title_override(title)
title = title or file.title
file.oldtitle = file.oldtitle or file.title
if title == "revert" and file.title == opts.force_title then
title_change(_, mp.get_property("media-title"), true)
elseif title == "revert" and opts.force_title ~= "no" then
file.title = opts.force_title
elseif title == "revert" then
file.title = file.oldtitle
else
file.title = title
end
print("title changed to " .. file.title)
mp.osd_message("streamsave: title changed to " .. file.title)
end
local function path_override(value)
value = value or opts.save_directory
file.oldpath = file.oldpath or opts.save_directory
if value == "revert" then
opts.save_directory = file.oldpath
else
opts.save_directory = value
end
file.path = mp.command_native({"expand-path", opts.save_directory})
print("Output directory changed to " .. opts.save_directory)
mp.osd_message("streamsave: directory changed to " .. opts.save_directory)
end
local function label_override(value)
if value == "cycle" then
if opts.output_label == "increment" then
value = "range"
elseif opts.output_label == "range" then
value = "timestamp"
elseif opts.output_label == "timestamp" then
value = "overwrite"
elseif opts.output_label == "overwrite" then
value = "chapter"
else
value = "increment"
end
end
opts.output_label = value or opts.output_label
validate_opts()
print("File label changed to " .. opts.output_label)
mp.osd_message("streamsave: label changed to " .. opts.output_label)
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 = {}
print("Range marks disabled")
mp.osd_message("streamsave: range marks disabled")
elseif value == "yes" then
opts.range_marks = true
chapter_points()
print("Range marks enabled")
mp.osd_message("streamsave: range marks enabled")
else
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 not value or value == "no" then
opts.autostart = false
print("Autostart disabled")
mp.osd_message("streamsave: autostart disabled")
elseif value == "yes" then
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
local function autoend_override(value)
opts.autoend = value or opts.autoend
validate_opts()
cache.endsec = convert_time(opts.autoend)
observe_cache()
print("Autoend set to " .. opts.autoend)
mp.osd_message("streamsave: autoend set to " .. opts.autoend)
end
local function hostchange_override(value)
local hostchange = opts.hostchange
value = value == "cycle" and (not opts.hostchange and "yes" or "no") or value
if not value or value == "no" then
opts.hostchange = false
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
end
local function quit_override(value)
opts.quit = value or opts.quit
validate_opts()
autoquit()
print("Quit set to " .. opts.quit)
mp.osd_message("streamsave: quit set to " .. opts.quit)
end
local function piecewise_override(value)
if not value or value == "no" then
opts.piecewise = false
cache.part = 0
print("Piecewise dumping disabled")
mp.osd_message("streamsave: piecewise dumping disabled")
elseif value == "yes" then
opts.piecewise = true
cache.endsec = convert_time(opts.autoend)
print("Piecewise dumping enabled")
mp.osd_message("streamsave: piecewise dumping enabled")
else
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")
if (loop.a and loop.b) and (loop.a > loop.b) then
loop.a, loop.b = loop.b, loop.a
mp.set_property_number("ab-loop-a", loop.a)
mp.set_property_number("ab-loop-b", loop.b)
end
end
local function loop_range()
local a_loop_osd = mp.get_property_osd("ab-loop-a")
local b_loop_osd = mp.get_property_osd("ab-loop-b")
loop.range = a_loop_osd .. " - " .. b_loop_osd
return loop.range
end
local function set_name(label)
return file.path .. "/" .. file.title .. label .. file.ext
end
local function increment_filename()
if set_name(-(file.inc or 1)) ~= file.name then
file.inc = 1
file.name = set_name(-file.inc)
end
-- check if file exists
while utils.file_info(file.name) do
file.inc = file.inc + 1
file.name = set_name(-file.inc)
end
end
local function range_stamp(mode)
local file_range
if mode == "ab" then
file_range = "-[" .. loop_range():gsub(":", ".") .. "]"
elseif mode == "current" then
local file_pos = mp.get_property_osd("playback-time", "0")
file_range = "-[" .. 0 .. " - " .. file_pos:gsub(":", ".") .. "]"
else
-- range tag is incompatible with full dump, fallback to increments
increment_filename()
return
end
file.name = set_name(file_range)
-- check if file exists, append increments if so
local i = 1
while utils.file_info(file.name) do
i = i + 1
file.name = set_name(file_range .. -i)
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 = {
(not quiet or nil) and "osd-msg",
},
filename = file_name,
}
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
command["end"] = file_pos or "no"
end
return command
end
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
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, 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()
elseif opts.output_label == "range" then
range_stamp(mode)
elseif opts.output_label == "timestamp" then
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
file.pending = (file.pending or 0) + 1
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 loop.continuous and file.pending == 1 then
print("Dumping cache continuously to: " .. file.name)
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
--[[ This command attempts to align the A-B loop points to keyframes.
Use align-cache if you want to know which range will likely be dumped.
Keep in mind this changes the A-B loop points you've set.
This is sometimes inaccurate. Calling align_cache() again will reset the points
to their initial values. ]]
local function align_cache()
if not loop.aligned then
range_flip()
loop.a_revert = loop.a
loop.b_revert = loop.b
mp.command("ab-loop-align-cache")
loop.aligned = true
print("Adjusted range: " .. loop_range())
else
mp.set_property_native("ab-loop-a", loop.a_revert)
mp.set_property_native("ab-loop-b", loop.b_revert)
loop.aligned = false
print("Loop points reverted to: " .. loop_range())
mp.osd_message("A-B loop: " .. loop.range)
end
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 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
for i = #ab_chapters + 1, #current_chapters do
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
if not updated then
mp.set_property_native("chapter-list", chapter_list)
end
else
if loop.a then
ab_chapters[1] = {
title = "A loop point",
time = loop.a
}
end
if loop.b and not loop.a then
ab_chapters[1] = {
title = "B loop point",
time = loop.b
}
elseif loop.b then
ab_chapters[2] = {
title = "B loop point",
time = loop.b
}
end
mp.set_property_native("chapter-list", ab_chapters)
end
end
-- stops writing the file
local function stop()
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(get_seekable_cache)
cache.endsec = convert_time(opts.autoend)
cache.observed = false
end
cache.part = 0
cache.dumped = false
cache.switch = true
end
reset()
-- 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", {})
local seekable_ranges = cache_state["seekable-ranges"] or {}
if prop then
if range_check ~= false and
(#seekable_ranges == 0
or not cache_state["cache-end"])
then
reset()
cache.use = opts.piecewise
observe_cache()
end
return
end
local seekable_ends = {0}
for i, range in ipairs(seekable_ranges) do
seekable_ends[i] = range["end"] or 0
end
return math.max(0, unpack(seekable_ends))
end
-- 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()
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 not cache_time then
reset()
cache.use = opts.piecewise
observe_cache()
return
end
-- cache write according to automatic options
if opts.autostart and not cache.dumped
and (not cache.endsec or cache_time < cache.endsec
or opts.piecewise)
then
if opts.piecewise and cache.part ~= 0 then
cache.dumped = cache_write("ab")
else
cache.dumped = cache_write("continuous", opts.hostchange)
-- update the piece time if there's a track/seeking reset
cache.part = cache.use and cache.dumped and cache_time or 0
cache.use = cache.use and cache.part == 0
end
end
-- the seekable ranges update slowly, which is why they're used to check
-- against switches for increased certainty, but this means the switch properties
-- should be watched only when the ranges exist
if cache.switch and get_seekable_cache() ~= 0 then
cache.switch = false
mp.observe_property("current-tracks/audio/id", "number", get_seekable_cache)
mp.observe_property("current-tracks/video/id", "number", get_seekable_cache)
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 then
mp.unobserve_property(automatic)
cache.observed = false
return
end
-- stop cache dump
if cache.endsec and cache.dumped and
cache_time - cache.part >= cache.endsec
then
if opts.piecewise then
cache.part = get_seekable_cache()
mp.set_property_number("ab-loop-a", cache.part)
mp.set_property("ab-loop-b", "no")
-- try and make the next piece start on the final keyframe of this piece
loop.aligned = false
align_cache()
cache.dumped = false
else
cache.endsec = nil
end
stop()
end
end
function autoquit()
if opts.quit == "no" then
if file.quit_timer then
file.quit_timer:kill()
end
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
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
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
reset()
end
end
-- 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
or script-opts=ytdl_hook-use_manifests=yes ]]
mp.observe_property("audio-codec-name", "string", container)
mp.observe_property("video-format", "string", container)
mp.observe_property("file-format", "string", container)
--[[ Loading chapters can be slow especially if they're passed from
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()
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-mode", mode_switch)
mp.register_script_message("streamsave-title", title_override)
mp.register_script_message("streamsave-extension", format_override)
mp.register_script_message("streamsave-path", path_override)
mp.register_script_message("streamsave-label", label_override)
mp.register_script_message("streamsave-marks", marks_override)
mp.register_script_message("streamsave-autostart", autostart_override)
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)
mp.add_key_binding("Alt+x", "align-cache", align_cache)
mp.add_key_binding("Ctrl+z", "cache-write",
function() cache_write(opts.dump_mode)
end)