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 from shlex import quote
import mpv import mpv
import time import time
import sys
import re import re
fzf = FzfPrompt() fzf = FzfPrompt()
if get_config('enable_persistent_cache'): 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') player.command('script-message', 'streamsave-path', 'cache')
else: else:
player = mpv.MPV(cache=True, demuxer_max_bytes=25*1024*1024) player = mpv.MPV(cache=True, demuxer_max_bytes=25*1024*1024)

View File

@ -25,18 +25,22 @@ end
function make_cache_track(url) function make_cache_track(url)
mp.command('script-message streamsave-autostart no')
find_uuid = "%x+-%x+-%x+-%x+-%x+" find_uuid = "%x+-%x+-%x+-%x+-%x+"
uuid = string.sub(url, string.find(url, find_uuid)) uuid = string.sub(url, string.find(url, find_uuid))
host = get_url_host(url) host = get_url_host(url)
cache_path_file = 'cache/' .. host .. '/' .. uuid .. '.mkv' cache_path_file = 'cache/' .. host .. '/' .. uuid .. '.mkv'
cache_path_named_file = 'cache/' .. host .. '/' .. uuid .. '.mkv'
if false == file_exists(cache_path_file) then if false == file_exists(cache_path_file) then
createDir('cache/' .. host .. '/') createDir('cache/' .. host .. '/')
msg.verbose('Caching ' .. cache_path_file .. '') msg.verbose('Caching ' .. cache_path_file .. '')
mp.command('script-message streamsave-title ' .. uuid .. '') 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.set_property('script-opts/media-uuid', uuid)
mp.command('script-message streamsave-extension .mkv') mp.command('script-message streamsave-extension .mkv')
mp.command('script-message streamsave-path cache/' .. host .. '') mp.command('script-message streamsave-path cache/' .. host .. '')
mp.command('script-message streamsave-rec') mp.command('script-message streamsave-autostart yes')
else else
msg.verbose('Already cached ' .. cache_path_file .. '') msg.verbose('Already cached ' .. cache_path_file .. '')
os.execute('touch ' .. cache_path_file .. '') os.execute('touch ' .. cache_path_file .. '')
@ -52,3 +56,11 @@ mp.add_hook("on_load", 11, function()
make_cache_track(url) make_cache_track(url)
end end
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 streamsave.lua
Version 0.20.6 Version 0.23.2
2022-10-13 2023-5-21
https://github.com/Sagnac/streamsave https://github.com/Sagnac/streamsave
NOTE: Modified
mpv script aimed at saving live streams and clipping online videos without encoding. 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 * Automatic determination of the output file name and format
* Option to specify the preferred output directory * 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 * Prevention of file overwrites
* Acceptance of inverted loop ranges, allowing the end point to be set first * Acceptance of inverted loop ranges, allowing the end point to be set first
* Dynamic chapter indicators on the OSC displaying the clipping interval * Dynamic chapter indicators on the OSC displaying the clipping interval
* Option to track HLS packet drops
* Automated stream saving * Automated stream saving
* Workaround for some DAI HLS streams served from .m3u8 where the host changes * 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=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 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. 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=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 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 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=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. 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). 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). 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. 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. 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: Automation Options:
The autostart and autoend options are used for automated stream capturing. 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. 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. 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 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, 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. 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 -- change these in streamsave.conf
local opts = { local opts = {
save_directory = [[.]], -- output file directory save_directory = [[.]], -- output file directory
dump_mode = "continuous", -- <ab|current|continuous> dump_mode = "ab", -- <ab|current|continuous|chapter|segments>
output_label = "overwrite", -- <increment|range|timestamp|overwrite> output_label = "increment", -- <increment|range|timestamp|overwrite|chapter>
force_extension = ".mkv", -- <no|.ext> extension will be .ext if set force_extension = "no", -- <no|.ext> extension will be .ext if set
force_title = "no", -- <no|title> custom title used for the filename force_title = "no", -- <no|title> custom title used for the filename
range_marks = false, -- <yes|no> set chapters at A-B loop points? 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 autoend = "no", -- <no|HH:MM:SS> cache time to stop at
hostchange = false, -- <yes|no> use if the host changes mid stream 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 quit = "no", -- <no|HH:MM:SS> quits player at specified time
piecewise = false, -- <yes|no> writes stream in parts with autoend piecewise = false, -- <yes|no> writes stream in parts with autoend
} }
@ -141,8 +160,12 @@ local file = {
title, -- media title title, -- media title
inc, -- filename increments inc, -- filename increments
ext, -- file extension ext, -- file extension
loaded, -- flagged once the initial load has taken place
pending, -- number of files pending write completion (max 2) pending, -- number of files pending write completion (max 2)
queue, -- cache_write queue in case of multiple write requests 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 oldtitle, -- initialized if title is overridden, allows revert
oldext, -- initialized if format is overridden, allows revert oldext, -- initialized if format is overridden, allows revert
oldpath, -- initialized if directory is overriden, 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 b_revert, -- B loop point prior to keyframe alignment
range, -- A-B loop range range, -- A-B loop range
aligned, -- are the loop points aligned to keyframes? aligned, -- are the loop points aligned to keyframes?
continuous, -- is the writing continuous?
} }
local cache = { local cache = {
dumped, -- autowrite cache state (serves as an autowrite request) dumped, -- autowrite cache state (serves as an autowrite request)
observed, -- whether the cache time is being observed observed, -- whether the cache time is being observed
endsec, -- user specified autoend cache time in seconds 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 seekend, -- seekable cache end timestamp
part, -- approx. end time of last piece / start time of next piece part, -- approx. end time of last piece / start time of next piece
switch, -- request to observe track switches and seeking switch, -- request to observe track switches and seeking
use, -- use cache_time instead of seekend for initial piece 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 track = {
local observe_cache vid, -- video track id
local continuous aid, -- audio track id
local write_file sid, -- subtitle track id
local reset restart, -- hostchange interval where subsequent reloads are immediate
local title_change suspend, -- suspension interval on track-list changes
local container }
local segments = {} -- chapter segments set for writing
local chapter_list = {} -- initial chapter list local chapter_list = {} -- initial chapter list
local ab_chapters = {} -- A-B loop point chapters 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 title_change
local i, j, H, M, S = value:find("(%d+):(%d+):(%d+)") local container
if not i then local get_chapters
return local chapter_points
else 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 return H*3600 + M*60 + S
end end
end end
@ -199,16 +229,19 @@ local function validate_opts()
if opts.output_label ~= "increment" and if opts.output_label ~= "increment" and
opts.output_label ~= "range" and opts.output_label ~= "range" and
opts.output_label ~= "timestamp" and opts.output_label ~= "timestamp" and
opts.output_label ~= "overwrite" opts.output_label ~= "overwrite" and
opts.output_label ~= "chapter"
then then
msg.warn("Invalid output_label '" .. opts.output_label .. "'") msg.error("Invalid output_label '" .. opts.output_label .. "'")
opts.output_label = "increment" opts.output_label = "increment"
end end
if opts.dump_mode ~= "ab" and if opts.dump_mode ~= "ab" and
opts.dump_mode ~= "current" 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 then
msg.warn("Invalid dump_mode '" .. opts.dump_mode .. "'") msg.error("Invalid dump_mode '" .. opts.dump_mode .. "'")
opts.dump_mode = "ab" opts.dump_mode = "ab"
end end
if opts.autoend ~= "no" then if opts.autoend ~= "no" then
@ -216,15 +249,15 @@ local function validate_opts()
cache.endsec = convert_time(opts.autoend) cache.endsec = convert_time(opts.autoend)
end end
if not convert_time(opts.autoend) then 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.") "'. Use HH:MM:SS format.")
opts.autoend = "no" opts.autoend = "no"
end end
end end
if opts.quit ~= "no" then if opts.quit ~= "no" then
quitseconds = convert_time(opts.quit) file.quitsec = convert_time(opts.quit)
if not quitseconds then if not file.quitsec then
msg.warn("Invalid quit value '" .. opts.quit .. msg.error("Invalid quit value '" .. opts.quit ..
"'. Use HH:MM:SS format.") "'. Use HH:MM:SS format.")
opts.quit = "no" opts.quit = "no"
end end
@ -249,17 +282,22 @@ local function update_opts(changed)
if opts.range_marks then if opts.range_marks then
chapter_points() chapter_points()
else else
if not get_chapters() then
mp.set_property_native("chapter-list", chapter_list)
end
ab_chapters = {} ab_chapters = {}
mp.set_property_native("chapter-list", chapter_list)
end end
end end
if changed["autoend"] then if changed["autoend"] then
cache.endsec = convert_time(opts.autoend) cache.endsec = convert_time(opts.autoend)
observe_cache() observe_cache()
end end
if changed["autostart"] or changed["hostchange"] then if changed["autostart"] then
observe_cache() observe_cache()
end end
if changed["hostchange"] then
observe_tracks(opts.hostchange)
end
if changed["quit"] then if changed["quit"] then
autoquit() autoquit()
end end
@ -268,6 +306,9 @@ local function update_opts(changed)
elseif changed["piecewise"] then elseif changed["piecewise"] then
cache.endsec = convert_time(opts.autoend) cache.endsec = convert_time(opts.autoend)
end end
if changed["track_packets"] then
packet_events(opts.track_packets)
end
end end
options.read_options(opts, "streamsave", update_opts) options.read_options(opts, "streamsave", update_opts)
@ -281,6 +322,10 @@ local function mode_switch(value)
value = "current" value = "current"
elseif opts.dump_mode == "current" then elseif opts.dump_mode == "current" then
value = "continuous" value = "continuous"
elseif opts.dump_mode == "continuous" then
value = "chapter"
elseif opts.dump_mode == "chapter" then
value = "segments"
else else
value = "ab" value = "ab"
end end
@ -297,8 +342,16 @@ local function mode_switch(value)
opts.dump_mode = "current" opts.dump_mode = "current"
print("Current position mode") print("Current position mode")
mp.osd_message("Cache write mode: Current position") 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 else
msg.warn("Invalid dump mode '" .. value .. "'") msg.error("Invalid dump mode '" .. value .. "'")
end end
end end
@ -321,12 +374,13 @@ function container(_, _, req)
local file_format = mp.get_property("file-format") local file_format = mp.get_property("file-format")
if not file_format then if not file_format then
reset() reset()
observe_tracks()
return end return end
if opts.force_extension ~= "no" and not req then if opts.force_extension ~= "no" and not req then
file.ext = opts.force_extension file.ext = opts.force_extension
observe_cache() observe_cache()
return end return end
if string.find(file_format, "mp4") if string.match(file_format, "mp4")
or ((video == "h264" or video == "av1" or not video) and or ((video == "h264" or video == "av1" or not video) and
(audio == "aac" or not audio)) (audio == "aac" or not audio))
then then
@ -339,6 +393,7 @@ function container(_, _, req)
file.ext = ".mkv" file.ext = ".mkv"
end end
observe_cache() observe_cache()
observe_tracks()
file.oldext = nil file.oldext = nil
end end
@ -395,6 +450,8 @@ local function label_override(value)
value = "timestamp" value = "timestamp"
elseif opts.output_label == "timestamp" then elseif opts.output_label == "timestamp" then
value = "overwrite" value = "overwrite"
elseif opts.output_label == "overwrite" then
value = "chapter"
else else
value = "increment" value = "increment"
end end
@ -408,8 +465,10 @@ end
local function marks_override(value) local function marks_override(value)
if not value or value == "no" then if not value or value == "no" then
opts.range_marks = false opts.range_marks = false
if not get_chapters() then
mp.set_property_native("chapter-list", chapter_list)
end
ab_chapters = {} ab_chapters = {}
mp.set_property_native("chapter-list", chapter_list)
print("Range marks disabled") print("Range marks disabled")
mp.osd_message("streamsave: range marks disabled") mp.osd_message("streamsave: range marks disabled")
elseif value == "yes" then elseif value == "yes" then
@ -418,17 +477,12 @@ local function marks_override(value)
print("Range marks enabled") print("Range marks enabled")
mp.osd_message("streamsave: range marks enabled") mp.osd_message("streamsave: range marks enabled")
else 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") mp.osd_message("streamsave: invalid input; use yes or no")
end end
end end
local function autostart_override(value) 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 if not value or value == "no" then
opts.autostart = false opts.autostart = false
print("Autostart disabled") print("Autostart disabled")
@ -437,6 +491,10 @@ local function autostart_override(value)
opts.autostart = true opts.autostart = true
print("Autostart enabled") print("Autostart enabled")
mp.osd_message("streamsave: 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 end
observe_cache() observe_cache()
end end
@ -451,24 +509,31 @@ local function autoend_override(value)
end end
local function hostchange_override(value) local function hostchange_override(value)
local hostchange = opts.hostchange
value = value == "cycle" and (not opts.hostchange and "yes" or "no") or value 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 if not value or value == "no" then
opts.hostchange = false opts.hostchange = false
mp.unobserve_property(reload)
local timer = cache.restart and cache.restart:kill()
print("Hostchange disabled") print("Hostchange disabled")
mp.osd_message("streamsave: hostchange disabled") mp.osd_message("streamsave: hostchange disabled")
elseif value == "yes" then elseif value == "yes" then
opts.hostchange = true opts.hostchange = true
print("Hostchange enabled") print("Hostchange enabled")
mp.osd_message("streamsave: 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
observe_cache()
end end
local function quit_override(value) local function quit_override(value)
@ -491,11 +556,33 @@ local function piecewise_override(value)
print("Piecewise dumping enabled") print("Piecewise dumping enabled")
mp.osd_message("streamsave: piecewise dumping enabled") mp.osd_message("streamsave: piecewise dumping enabled")
else 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") mp.osd_message("streamsave: invalid input; use yes or no")
end end
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() local function range_flip()
loop.a = mp.get_property_number("ab-loop-a") loop.a = mp.get_property_number("ab-loop-a")
loop.b = mp.get_property_number("ab-loop-b") loop.b = mp.get_property_number("ab-loop-b")
@ -550,6 +637,47 @@ local function range_stamp(mode)
end end
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 function write_set(mode, file_name, file_pos, quiet)
local command = { local command = {
_flags = { _flags = {
@ -559,6 +687,11 @@ local function write_set(mode, file_name, file_pos, quiet)
} }
if mode == "ab" then if mode == "ab" then
command["name"] = "ab-loop-dump-cache" 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 else
command["name"] = "dump-cache" command["name"] = "dump-cache"
command["start"] = 0 command["start"] = 0
@ -567,18 +700,67 @@ local function write_set(mode, file_name, file_pos, quiet)
return command return command
end 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 if not (file.title and file.ext) then
return end 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 {} file.queue = file.queue or {}
-- honor extra write requests when pending queue is full -- honor extra write requests when pending queue is full
-- but limit number of outstanding write requests to be fulfilled -- but limit number of outstanding write requests to be fulfilled
if #file.queue < 10 then if #file.queue < 10 then
table.insert(file.queue, {mode, quiet}) table.insert(file.queue, {mode, quiet, chapter})
end end
return end return end
range_flip() 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 -- evaluate tagging conditions and set file name
if opts.output_label == "increment" then if opts.output_label == "increment" then
increment_filename() increment_filename()
@ -588,42 +770,27 @@ local function cache_write(mode, quiet)
file.name = set_name(-os.time()) file.name = set_name(-os.time())
elseif opts.output_label == "overwrite" then elseif opts.output_label == "overwrite" then
file.name = set_name("") 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 end
-- dump cache according to mode -- dump cache according to mode
local file_pos local file_pos
local file_name = file.name -- scope reduction so callback verifies correct file
file.pending = (file.pending or 0) + 1 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 if mode == "current" then
file_pos = mp.get_property_number("playback-time", 0) file_pos = mp.get_property_number("playback-time", 0)
elseif continuous and file.pending == 1 then elseif loop.continuous and file.pending == 1 then
print("Dumping cache continuously to:" .. file_name) print("Dumping cache continuously to: " .. file.name)
end end
write_file = mp.command_native_async ( local commands = write_set(mode, file.name, file_pos, quiet)
write_set(mode, file_name, file_pos, quiet), local callback = on_write_finish(cache_write, mode, file.name)
function(success, _, command_error) file.writing = mp.command_native_async(commands, callback)
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
)
return true return true
end end
@ -649,16 +816,15 @@ local function align_cache()
end end
end end
-- creates chapters at A-B loop points function get_chapters()
function chapter_points()
if not opts.range_marks then
return end
local current_chapters = mp.get_property_native("chapter-list", {}) 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 -- 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$") not string.match(current_chapters[1]["title"], "^[AB] loop point$")
then then
chapter_list = current_chapters chapter_list = current_chapters
updated = true
-- if a script has added chapters after A-B points are set then -- if a script has added chapters after A-B points are set then
-- add those to the original chapter list -- add those to the original chapter list
elseif #current_chapters > #ab_chapters then elseif #current_chapters > #ab_chapters then
@ -666,12 +832,22 @@ function chapter_points()
table.insert(chapter_list, current_chapters[i]) table.insert(chapter_list, current_chapters[i])
end end
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 = {} ab_chapters = {}
-- restore original chapter list if A-B points are cleared -- restore original chapter list if A-B points are cleared
-- otherwise set chapters to A-B points -- otherwise set chapters to A-B points
range_flip() range_flip()
if not loop.a and not loop.b then 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 else
if loop.a then if loop.a then
ab_chapters[1] = { ab_chapters[1] = {
@ -696,36 +872,53 @@ end
-- stops writing the file -- stops writing the file
local function stop() local function stop()
mp.abort_async_command(write_file or {}) mp.abort_async_command(file.writing or {})
end end
function reset() function reset()
if cache.observed or cache.dumped then if cache.observed or cache.dumped then
stop() stop()
mp.unobserve_property(automatic) mp.unobserve_property(automatic)
mp.unobserve_property(reload)
mp.unobserve_property(get_seekable_cache) mp.unobserve_property(get_seekable_cache)
cache.endsec = convert_time(opts.autoend) cache.endsec = convert_time(opts.autoend)
cache.observed = false cache.observed = false
end end
cache.prior = 0
cache.part = 0 cache.part = 0
cache.dumped = false cache.dumped = false
cache.switch = true cache.switch = true
end end
reset() 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 -- use the seekable part of the cache for more accurate timestamps
local cache_state = mp.get_property_native("demuxer-cache-state", {}) 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 {} local seekable_ranges = cache_state["seekable-ranges"] or {}
if prop then if prop then
if range_check ~= false and if range_check ~= false and
(#seekable_ranges == 0 (#seekable_ranges == 0
or not mp.get_property_number("demuxer-cache-time")) or not cache_state["cache-end"])
then then
reset() reset()
cache.use = opts.piecewise cache.use = opts.piecewise
@ -737,45 +930,67 @@ function get_seekable_cache(prop, range_check, underrun)
for i, range in ipairs(seekable_ranges) do for i, range in ipairs(seekable_ranges) do
seekable_ends[i] = range["end"] or 0 seekable_ends[i] = range["end"] or 0
end end
cache.seekend = math.max(0, unpack(seekable_ends)) return math.max(0, unpack(seekable_ends))
return cache.seekend
end end
function reload(_, play_time) -- seamlessly reload on inserts (hostchange)
local cache_duration = mp.get_property_number("demuxer-cache-duration") local function seamless(_, cache_state)
if play_time and play_time >= cache.seekend - 0.25 cache_state = cache_state or {}
or cache_duration and math.abs(cache.prior - cache_duration) > 4800 local reader = math.abs(cache_state["reader-pts"] or 0)
or get_seekable_cache(nil, false, true) 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 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() reset()
cache.restart = cache.restart or mp.add_timeout(300, function() end) observe_tracks(false)
cache.restart:resume() cache.observed = true
msg.warn("Reloading stream due to host change.") cache.prior = math.abs(mp.get_property_number("demuxer-cache-duration", 4E3))
mp.command("playlist-play-index current") cache.seekend = get_seekable_cache()
mp.observe_property("demuxer-cache-state", "native", seamless)
end end
end end
function automatic(_, cache_time) function automatic(_, cache_time)
if opts.hostchange and cache.prior ~= 0 if not cache_time then
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
reset() reset()
cache.use = opts.piecewise cache.use = opts.piecewise
observe_cache() observe_cache()
@ -805,12 +1020,9 @@ function automatic(_, cache_time)
mp.observe_property("seeking", "bool", get_seekable_cache) mp.observe_property("seeking", "bool", get_seekable_cache)
end end
-- unobserve cache time if not needed -- unobserve cache time if not needed
if cache.dumped and not cache.switch if cache.dumped and not cache.switch and not cache.endsec then
and not cache.endsec and not opts.hostchange
then
mp.unobserve_property(automatic) mp.unobserve_property(automatic)
cache.observed = false cache.observed = false
cache.prior = 0
return return
end end
-- stop cache dump -- stop cache dump
@ -830,34 +1042,104 @@ function automatic(_, cache_time)
end end
stop() stop()
end end
cache.prior = cache_time
end end
function autoquit() function autoquit()
if opts.quit == "no" then if opts.quit == "no" then
if quit_timer then if file.quit_timer then
quit_timer:kill() file.quit_timer:kill()
end end
elseif not quit_timer then elseif not file.quit_timer then
quit_timer = mp.add_timeout(quitseconds, file.quit_timer = mp.add_timeout(file.quitsec,
function() function()
stop() stop()
mp.command("quit") mp.command("quit")
print("Quit after " .. opts.quit) print("Quit after " .. opts.quit)
end) end)
else else
quit_timer["timeout"] = quitseconds file.quit_timer["timeout"] = file.quitsec
quit_timer:kill() file.quit_timer:kill()
quit_timer:resume() file.quit_timer:resume()
end end
end end
autoquit() 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 -- cache time observation switch for runtime changes
function observe_cache() function observe_cache()
local network = mp.get_property_bool("demuxer-via-network") 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 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) mp.observe_property("demuxer-cache-time", "number", automatic)
cache.observed = true cache.observed = true
elseif (cache.observed or cache.dumped) and (not obs_xyz or not network) then elseif (cache.observed or cache.dumped) and (not obs_xyz or not network) then
@ -865,7 +1147,30 @@ function observe_cache()
end end
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 --[[ video and audio formats observed in order to handle track changes
useful if e.g. --script-opts=ytdl_hook-all_formats=yes 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 an external file, so make sure existing chapters are not overwritten
by observing A-B loop changes only after the file is loaded. ]] by observing A-B loop changes only after the file is loaded. ]]
local function on_file_load() local function on_file_load()
mp.observe_property("ab-loop-a", "native", chapter_points) if file.loaded then
mp.observe_property("ab-loop-b", "native", chapter_points) 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 end
mp.register_event("file-loaded", on_file_load) 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-mode", mode_switch)
mp.register_script_message("streamsave-title", title_override) mp.register_script_message("streamsave-title", title_override)
mp.register_script_message("streamsave-extension", format_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-hostchange", hostchange_override)
mp.register_script_message("streamsave-quit", quit_override) mp.register_script_message("streamsave-quit", quit_override)
mp.register_script_message("streamsave-piecewise", piecewise_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("Alt+z", "mode-switch", function() mode_switch("cycle") end)
mp.add_key_binding("Ctrl+x", "stop-cache-write", stop) mp.add_key_binding("Ctrl+x", "stop-cache-write", stop)