nixos-private-dots/modules/home-manager/mpv/scripts/streamsave.lua
2024-07-31 06:18:16 +03:00

449 lines
No EOL
13 KiB
Lua

--[[
streamsave.lua
Version 0.27.3-lite
2023-02-21
https://github.com/Sagnac/streamsave/tree/lite
]]
local options = require 'mp.options'
local utils = require 'mp.utils'
local msg = require 'mp.msg'
-- default user settings
-- change these in streamsave.conf
local opts = {
save_directory = [[]], -- output file directory
dump_mode = "ab", -- <ab|current|continuous>
output_label = "increment", -- <increment|range|timestamp|overwrite>
force_extension = "", -- <.ext> extension will be .ext if set
force_title = "", -- <title> custom title used for the filename
range_marks = true, -- <yes|no> set chapters at A-B loop points?
}
local cycle_modes = {
"ab",
"current",
"continuous",
}
local modes = {}
for i, v in ipairs(cycle_modes) do
modes[v] = i
end
local mode_info = {
continuous = "Continuous",
ab = "A-B loop",
current = "Current position"
}
local labels = {
increment = true,
range = true,
timestamp = true,
overwrite = true,
}
setmetatable(cycle_modes, {
__index = function(t) return t[1] end,
__call = function(t) return t[modes[opts.dump_mode] + 1] end
})
-- for internal use
local file = {
name, -- file name (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)
writing, -- file writing object returned by the write command
}
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 update = {} -- option update functions, {mode, label} ⊈ update
local chapter_list = {} -- initial chapter list
local ab_chapters = {} -- A-B loop point chapters
local webm = {
vp8 = true,
vp9 = true,
av1 = true,
opus = true,
vorbis = true,
none = true,
}
local mp4 = {
h264 = true,
hevc = true,
av1 = true,
mp3 = true,
flac = true,
aac = true,
none = true,
}
local title_change
local container
local cache_write
local get_chapters
local chapter_points
local function enabled(option)
return string.len(option) > 0
end
local function validate_opts()
if not modes[opts.dump_mode] then
msg.error("Invalid dump_mode '" .. opts.dump_mode .. "'")
opts.dump_mode = "ab"
end
if not labels[opts.output_label] then
msg.error("Invalid output_label '" .. opts.output_label .. "'")
opts.output_label = "increment"
end
end
local function append_slash(path)
if not path:match("[\\/]", -1) then
return path .. "/"
else
return path
end
end
function update.save_directory()
if #opts.save_directory == 0 then
file.path = opts.save_directory
return
end
-- expand mpv meta paths (e.g. ~~/directory)
opts.save_directory = append_slash(opts.save_directory)
file.path = append_slash(mp.command_native{"expand-path", opts.save_directory})
end
function update.force_title()
if enabled(opts.force_title) then
file.title = opts.force_title
elseif file.title then
title_change(_, mp.get_property("media-title"))
end
end
function update.force_extension()
if enabled(opts.force_extension) then
file.ext = opts.force_extension
else
container()
end
end
function update.range_marks()
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
local function update_opts(changed)
validate_opts()
for opt, _ in pairs(changed) do
if update[opt] then
update[opt]()
end
end
end
options.read_options(opts, "streamsave", update_opts)
update_opts{force_title = true, save_directory = true}
-- dump mode switching
local function mode_switch(value)
if value == "cycle" then
value = cycle_modes()
end
if not modes[value] then
msg.error(("Invalid dump mode '%s'."):format(value))
return
end
opts.dump_mode = value
local mode = mode_info[value]
print(mode, "mode" .. ".")
mp.osd_message("Cache write mode: " .. mode)
end
-- Set the principal part of the file name using the media title
function title_change(_, media_title)
if enabled(opts.force_title) or not media_title then
return end
-- Replacement of reserved file name characters on Windows
file.title = media_title:gsub("[\\/:*?\"<>|]", ".")
end
-- Determine container for standard formats
function container()
local audio = mp.get_property("audio-codec-name", "none")
local video = mp.get_property("video-format", "none")
local file_format = mp.get_property("file-format")
if not file_format then
file.ext = nil
return end
if enabled(opts.force_extension) then
file.ext = opts.force_extension
return end
if webm[video] and webm[audio] then
file.ext = ".webm"
elseif mp4[video] and mp4[audio] then
file.ext = ".mp4"
else
file.ext = ".mkv"
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, title)
title = title or file.title
return file.path .. 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_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"
else
command["name"] = "dump-cache"
command["start"] = 0
command["end"] = file_pos or "no"
end
return command
end
local function on_write_finish(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
end
end
function cache_write(mode, quiet, chapter)
if not (file.title and file.ext) or file.pending == 2 then
return end
range_flip()
-- 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("")
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
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(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", {})
-- make sure the 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
return true
end
-- if a script has added chapters after A-B points are set then
-- add those to the original chapter list
local current_len = #current_chapters
local ab_len = #ab_chapters
if current_len > ab_len then
local last = #chapter_list
for i = ab_len + 1, current_len do
last = last + 1
chapter_list[last] = current_chapters[i]
end
end
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
return
end
if loop.a then
ab_chapters[1] = {
title = "A loop point",
time = loop.a
}
end
if loop.b then
table.insert(ab_chapters, {
title = "B loop point",
time = loop.b
})
end
mp.set_property_native("chapter-list", ab_chapters)
end
-- stops writing the file
local function stop()
mp.abort_async_command(file.writing or {})
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)
--[[ 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.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)