-
Notifications
You must be signed in to change notification settings - Fork 4
/
streamsave.lua
1550 lines (1411 loc) · 52 KB
/
streamsave.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--[[
streamsave.lua
Version 0.28.0
2024-07-18
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 updates to all user options are also 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. Paths with or without a trailing slash are accepted.
Don't use quote marks 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
fallback_write=yes enables initiation of full continuous writes under A-B loop mode if no loop points are set.
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 use a Unix timestamp for 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).
This option can be set at runtime with script-message by passing force as an argument; e.g.:
script-message streamsave-extension .mkv force
This changes the format for the current stream and all subsequently loaded streams
(without `force` the setting is a one-shot setting for the present stream).
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 secondary `force` argument is supported as well when not using `revert`.
Property expansion is supported for all user-set titles. For example:
`force_title=streamsave_${media-title}` in streamsave.conf, or
`script-message streamsave-title ${duration}` at runtime.
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 to change settings at runtime and
temporarily override the output title or file extension.
Boolean-style options (yes/no) can be cycled by omitting the third argument.
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-marks
script-message streamsave-mode continuous
script-message streamsave-title "Global Title" force
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'
-- compatibility with Lua 5.1 (these are present in 5.2+ and LuaJIT)
local pack = table.pack or function(...) return {n = select("#", ...), ...} end
local unpack = unpack or table.unpack
-- default user settings
-- change these in streamsave.conf
local opts = {
save_directory = [[]], -- output file directory
dump_mode = "ab", -- <ab|current|continuous|chapter|segments>
fallback_write = false, -- <yes|no> full dump if no loop points are set
output_label = "increment", -- <increment|range|timestamp|overwrite|chapter>
force_extension = "", -- <.ext> extension will be .ext if set
force_title = "", -- <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
}
local cycle = {}
cycle.modes = {
"ab",
"current",
"continuous",
"chapter",
"segments",
}
local mode_info = {
continuous = "Continuous",
ab = "A-B loop",
current = "Current position",
chapter = "Chapter",
segments = "Segments",
append = {
chapter = " (single chapter)",
segments = " (all chapters)"
}
}
cycle.labels = {
"increment",
"range",
"timestamp",
"overwrite",
"chapter",
}
function cycle.set(s)
local opt = {}
for i, v in ipairs(cycle[s]) do
opt[v] = i
end
local mt = {
__index = function(t) return t[1] end,
__call = function(t, v) return t[opt[v] + 1] end
}
setmetatable(cycle[s], mt)
return opt
end
local modes = cycle.set("modes")
local labels = cycle.set("labels")
-- 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
length, -- 3-element table: directory, filename, & full path lengths
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
subs_off, -- whether subs were disabled on write due to incompatibility
quitsec, -- user specified quit time in seconds
quit_timer, -- player quit timer set according to quitsec
forced_title, -- unexpanded user set title
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 update = {} -- option update functions, {mode, label, on_demand} ⊈ update
local segments = {} -- chapter segments set for writing
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 reset
local get_seekable_cache
local automatic
local autoquit
local packet_events
local observe_cache
local observe_tracks
local MAX_PATH_LENGTH = 259
local UNICODE = "[%z\1-\127\194-\244][\128-\191]*"
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 enabled(option)
return string.len(option) > 0
end
local function throw(s, ...)
local try = function(...) msg.error(s:format(...)) end
if not pcall(try, ...) then
-- catch and explicitly cast in case of Lua 5.1 which doesn't coerce
-- non-string types while formatting with the string specifier
local args = pack(...)
for i = 1, args.n do
args[i] = tostring(args[i])
end
try(unpack(args, 1, args.n))
end
end
local function deprecate()
local warn = false
local options = {"force_extension", "force_title"}
for _, option in next, options do
if opts[option] == "no" then
opts[option] = ""
warn = true
end
end
if warn then
msg.warn('Deprecation warning: the default setting of "no" has been changed')
msg.warn('in favour of an empty string for the following options:')
msg.warn(table.concat(options, ", "))
msg.warn('Either delete these lines from your streamsave.conf')
msg.warn('or assign nothing / leave the value blank.')
end
end
local function show_possible_settings(t)
msg.warn("Possible settings are:")
msg.warn(utils.to_string(t))
end
local function validate_opts()
if not modes[opts.dump_mode] then
throw("Invalid dump_mode '%s'.", opts.dump_mode)
show_possible_settings(cycle.modes)
opts.dump_mode = "ab"
end
if not labels[opts.output_label] then
throw("Invalid output_label '%s'.", opts.output_label)
show_possible_settings(cycle.labels)
opts.output_label = "increment"
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
throw("Invalid autoend value '%s'. Use HH:MM:SS format.", opts.autoend)
opts.autoend = "no"
end
end
if opts.quit ~= "no" then
file.quitsec = convert_time(opts.quit)
if not file.quitsec then
throw("Invalid quit value '%s'. Use HH:MM:SS format.", opts.quit)
opts.quit = "no"
end
end
deprecate()
end
local function append_slash(path)
if not path:match("[\\/]", -1) then
return path .. "/"
else
return path
end
end
local function normalize(path)
-- handle absolute paths
if path:match("^/") or path:match("^%a:[\\/]") or path:match("^\\\\") then
return path
elseif path:match("^\\") then
-- path relative to cwd root
-- make sure the drive letter and volume separator are counted
-- the actual letter doesn't matter as this is only for length measurement
return "C:" .. path
end
-- resolve relative paths
path = append_slash(utils.getcwd() or "") .. path:gsub("^%.[\\/]", "")
-- relative paths with ../ are resolved by collapsing
-- the parent directories iteratively
local k
repeat
path, k = path:gsub("[\\/][^\\/]+[\\/]+%.%.[\\/]", "/", 1)
until k == 0
return path
end
local function get_path_length(path)
return (select(2, path:gsub(UNICODE, "")))
end
function update.save_directory()
if #opts.save_directory == 0 then
file.path = opts.save_directory
else
-- 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
file.length = {get_path_length(normalize(file.path))}
end
function update.force_title()
if enabled(opts.force_title) then
file.forced_title = opts.force_title
elseif file.forced_title then
title_change(_, mp.get_property("media-title"))
else
file.forced_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
function update.autoend()
cache.endsec = convert_time(opts.autoend)
observe_cache()
end
function update.autostart()
observe_cache()
end
function update.hostchange()
observe_tracks(opts.hostchange)
end
function update.quit()
autoquit()
end
function update.piecewise()
if not opts.piecewise then
cache.part = 0
else
cache.endsec = convert_time(opts.autoend)
end
end
function update.track_packets()
packet_events(opts.track_packets)
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}
local function push(t, v)
t[#t + 1] = v
end
-- dump mode switching
local function mode_switch(value)
if value == "cycle" then
value = cycle.modes(opts.dump_mode)
end
if not modes[value] then
throw("Invalid dump mode '%s'.", value)
show_possible_settings(cycle.modes)
return
end
opts.dump_mode = value
local mode = mode_info[value]
local append = mode_info.append[value] or ""
msg.info(mode, "mode" .. append .. ".")
mp.osd_message("Cache write mode: " .. mode .. append)
end
local function sanitize(title)
-- guard in case of an empty string
if #title == 0 then
return "streamsave"
end
-- replacement of reserved characters
title = title:gsub("[\\/:*?\"<>|]", ".")
-- avoid outputting dotfiles
title = title:gsub("^%.", ",")
return title
end
-- Set the principal part of the file name using the media title
function title_change(_, media_title, req)
if not media_title then
file.title = nil
return
end
if enabled(opts.force_title) and not req then
file.forced_title = opts.force_title
return
end
file.title = sanitize(media_title)
file.forced_title = ""
file.oldtitle = nil
end
local function local_mkv(file_format)
return not mp.get_property_bool("demuxer-via-network") and file_format == "mkv"
end
-- Determine container for standard formats
function container(_, _, req)
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
reset()
observe_tracks()
file.ext = nil
return end
if enabled(opts.force_extension) and not req then
file.ext = opts.force_extension
observe_cache()
observe_tracks()
return end
if webm[video] and webm[audio] then
file.ext = ".webm"
elseif mp4[video] and mp4[audio] and not local_mkv(file_format) then
file.ext = ".mp4"
else
file.ext = ".mkv"
end
observe_cache()
observe_tracks()
file.oldext = nil
end
local function cycle_bool_on_missing_arg(arg, opt)
return arg or (not opt and "yes" or "no")
end
local function throw_on_bool_invalidation(value)
throw("Invalid input '%s'. Use yes or no.", value)
mp.osd_message("streamsave: invalid input; use yes or no")
end
local function format_override(ext, force)
ext = ext or file.ext
file.oldext = file.oldext or file.ext
if force == "force" and ext ~= "revert" then
opts.force_extension = ext
file.ext = opts.force_extension
msg.info("file extension globally forced to", file.ext)
mp.osd_message("streamsave: file extension globally forced to " .. file.ext)
return
end
if ext == "revert" and file.ext == opts.force_extension then
if force == "force" then
msg.info("force_extension option reset to default.")
opts.force_extension = ""
end
container(_, _, true)
elseif ext == "revert" and enabled(opts.force_extension) then
file.ext = opts.force_extension
elseif ext == "revert" then
file.ext = file.oldext
else
file.ext = ext
end
msg.info("file extension changed to", file.ext)
mp.osd_message("streamsave: file extension changed to " .. file.ext)
end
local function title_override(title, force)
title = title or file.title
file.oldtitle = file.oldtitle or file.title
if force == "force" and title ~= "revert" then
opts.force_title = title
file.forced_title = opts.force_title
opts.output_label = "increment"
msg.info("title globally forced to", title)
mp.osd_message("streamsave: title globally forced to " .. title)
return
end
if title == "revert" and enabled(file.forced_title) then
if force == "force" then
msg.info("force_title option reset to default.")
opts.force_title = ""
end
title_change(_, mp.get_property("media-title"), true)
elseif title == "revert" and enabled(opts.force_title) then
file.forced_title = opts.force_title
elseif title == "revert" then
file.title = file.oldtitle
file.forced_title = ""
else
file.forced_title = title
end
title = enabled(file.forced_title) and file.forced_title or file.title
msg.info("title changed to", title)
mp.osd_message("streamsave: title changed to " .. title)
end
local function path_override(value)
value = value or "./"
file.oldpath = file.oldpath or opts.save_directory
if value == "revert" then
opts.save_directory = file.oldpath
else
opts.save_directory = value
end
update.save_directory()
msg.info("Output directory changed to", file.path)
mp.osd_message("streamsave: directory changed to " .. opts.save_directory)
end
local function label_override(value)
if value == "cycle" then
value = cycle.labels(opts.output_label)
end
if not labels[value] then
throw("Invalid output label '%s'.", value)
show_possible_settings(cycle.labels)
return
end
opts.output_label = value
msg.info("File label changed to", value)
mp.osd_message("streamsave: label changed to " .. value)
end
local function marks_override(value)
value = cycle_bool_on_missing_arg(value, opts.range_marks)
if value == "no" then
opts.range_marks = false
if not get_chapters() then
mp.set_property_native("chapter-list", chapter_list)
end
ab_chapters = {}
msg.info("Range marks disabled.")
mp.osd_message("streamsave: range marks disabled")
elseif value == "yes" then
opts.range_marks = true
chapter_points()
msg.info("Range marks enabled.")
mp.osd_message("streamsave: range marks enabled")
else
throw_on_bool_invalidation(value)
end
end
local function autostart_override(value)
value = cycle_bool_on_missing_arg(value, opts.autostart)
if value == "no" then
opts.autostart = false
msg.info("Autostart disabled.")
mp.osd_message("streamsave: autostart disabled")
elseif value == "yes" then
opts.autostart = true
msg.info("Autostart enabled.")
mp.osd_message("streamsave: autostart enabled")
else
throw_on_bool_invalidation(value)
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()
msg.info("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 = cycle_bool_on_missing_arg(value, hostchange)
if value == "no" then
opts.hostchange = false
msg.info("Hostchange disabled.")
mp.osd_message("streamsave: hostchange disabled")
elseif value == "yes" then
opts.hostchange = true
msg.info("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"
msg.info("Hostchange: On Demand", status .. ".")
mp.osd_message("streamsave: hostchange on_demand " .. status)
else
local allowed = "yes, no, or on_demand"
throw("Invalid input '%s'. Use %s.", value, 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()
msg.info("Quit set to", opts.quit)
mp.osd_message("streamsave: quit set to " .. opts.quit)
end
local function piecewise_override(value)
value = cycle_bool_on_missing_arg(value, opts.piecewise)
if value == "no" then
opts.piecewise = false
cache.part = 0
msg.info("Piecewise dumping disabled.")
mp.osd_message("streamsave: piecewise dumping disabled")
elseif value == "yes" then
opts.piecewise = true
cache.endsec = convert_time(opts.autoend)
msg.info("Piecewise dumping enabled.")
mp.osd_message("streamsave: piecewise dumping enabled")
else
throw_on_bool_invalidation(value)
end
end
local function packet_override(value)
local track_packets = opts.track_packets
value = cycle_bool_on_missing_arg(value, track_packets)
if value == "no" then
opts.track_packets = false
msg.info("Track packets disabled.")
mp.osd_message("streamsave: track packets disabled")
elseif value == "yes" then
opts.track_packets = true
msg.info("Track packets enabled.")
mp.osd_message("streamsave: track packets enabled")
else
throw_on_bool_invalidation(value)
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
-- property expansion of user-set titles
local function expand(title)
if enabled(title) then
file.title = sanitize(mp.command_native{"expand-text", title})
end
end
local function check_path_length()
if file.length[3] > MAX_PATH_LENGTH then
msg.warn("Path length exceeds", MAX_PATH_LENGTH, "characters")
msg.warn("and the title cannot be truncated further.")
msg.warn("The file may fail to write. Use a shorter save_directory path.")
end
end
local function set_path(label, title)
local name = title .. label .. file.ext
file.length[2] = get_path_length(name)
file.length[3] = file.length[1] + file.length[2]
return file.path .. name
end
local function set_name(label, title)
title = title or file.title
local path = set_path(label, title)
local path_length = file.length[3]
local diff = math.min(path_length - MAX_PATH_LENGTH, get_path_length(title) - 1)
if diff < 1 then
return path
else -- truncate
title = title:gsub(UNICODE:rep(diff) .. "$", "")
return set_path(label, title)
end
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 get_ranges()
local cache_state = mp.get_property_native("demuxer-cache-state", {})
local ranges = cache_state["seekable-ranges"] or {}
return ranges, cache_state
end
local function get_cache_start()
local seekable_ranges = get_ranges()
local seekable_starts = {0}
for i, range in ipairs(seekable_ranges) do
seekable_starts[i] = range["start"] or 0
end
return math.min(unpack(seekable_starts))
end
local function adjust_initial_chapter(chapter_list)
if not next(chapter_list) then
return
end
local threshold = 0.1
local set_zeroth = chapter_list[1]["time"] > threshold
local cache_start = get_cache_start()
if not set_zeroth and cache_start <= threshold then
chapter_list[1]["time"] = cache_start
end
return set_zeroth, cache_start
end
local function cache_check(k)
local seekable_ranges, cache_state = get_ranges()
local chapter = segments[k]
local chapt_start, chapt_end = chapter["start"], chapter["end"]
local chapter_cached = false
if chapt_end == "no" then
chapt_end = chapt_start
end
for _, range in ipairs(seekable_ranges) do
if chapt_start >= range["start"] and chapt_end <= range["end"] then
chapter_cached = true
break
end
end
if k == 1 and not chapter_cached then
segments = {}
msg.error("chapter must be fully cached")
end
return chapter_cached, seekable_ranges, cache_state
end
local function fully_cached(k)
local up_to_end, ranges, cache_state = cache_check(k)
return cache_state["bof-cached"] and up_to_end and #ranges == 1
end
local function write_chapter(chapter)
local chapter_list = mp.get_property_native("chapter-list", {})
local set_zeroth, cache_start = adjust_initial_chapter(chapter_list)
local zeroth_chapter = chapter == 0 and set_zeroth
if chapter_list[chapter] or zeroth_chapter then
segments[1] = {
["start"] = zeroth_chapter and cache_start
or chapter_list[chapter]["time"],
["end"] = chapter_list[chapter + 1]
and chapter_list[chapter + 1]["time"]
or "no",
["title"] = chapter .. ". " .. (not zeroth_chapter
and chapter_list[chapter]["title"] or file.title)
}
msg.info("Writing chapter", chapter, "....")
return cache_check(1)
else
msg.error("Chapter", chapter, "not found.")
end
end
local function extract_segments(n, chapter_list)
local set_zeroth, cache_start = adjust_initial_chapter(chapter_list)
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 set_zeroth then
table.insert(segments, 1, {
["start"] = cache_start,
["end"] = chapter_list[1]["time"],
["title"] = "0. " .. file.title
})
end
push(segments, {
["start"] = chapter_list[n]["time"],
["end"] = "no",
["title"] = n .. ". " .. (chapter_list[n]["title"] or file.title)
})
local k = #segments
msg.info("Writing out all", k, "chapters to separate files ....")
return k
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