streamsave updated; some caching fixes (may be)

This commit is contained in:
localhost_frssoft 2023-06-13 16:18:07 +03:00
parent 9f37a52a64
commit add2ef572c
3 changed files with 483 additions and 156 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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", -- <ab|current|continuous>
output_label = "overwrite", -- <increment|range|timestamp|overwrite>
force_extension = ".mkv", -- <no|.ext> extension will be .ext if set
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?
autostart = true, -- <yes|no> automatically dump cache at start?
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
}
@ -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
ab_chapters = {}
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"] 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
ab_chapters = {}
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
@ -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,41 +700,8 @@ local function write_set(mode, file_name, file_pos, quiet)
return command
end
local function cache_write(mode, quiet)
if not (file.title and file.ext) then
return end
if file.pending == 2 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})
end
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
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
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)
end
write_file = mp.command_native_async (
write_set(mode, file_name, file_pos, quiet),
function(success, _, command_error)
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
@ -613,17 +713,84 @@ local function cache_write(mode, quiet)
else
msg.error("File not written.")
end
if continuous and file.pending == 2 then
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 file.queue and #file.queue > 0 then
cache_write(file.queue[1][1], file.queue[1][2])
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
@ -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
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()
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)