Files
mpv-config/scripts/history-bookmark.lua
T
2026-03-27 07:06:16 +01:00

602 lines
18 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--lite version of the code written by sorayuki
--only keep the function to record the histroy and recover it
local mp = require 'mp'
local utils = require 'mp.utils'
local options = require 'mp.options'
local msg = require 'mp.msg' -- this is for debugging
local o = {
enabled = true,
-- eng=English, chs=Chinese Simplified
language = 'eng',
timeout = 15,
save_period = 30,
-- Set '/:dir%mpvconf%/historybookmarks' to use mpv config directory
-- OR change to '/:dir%script%/historybookmarks' for placing it in the same directory of script
-- OR change to '~~/historybookmarks' for sub path of mpv portable_config directory
-- OR write any variable using '/:var', such as: '/:var%APPDATA%/mpv/historybookmarks' or '/:var%HOME%/mpv/historybookmarks'
-- OR specify the absolute path
history_dir = "/:dir%mpvconf%/historybookmarks",
-- specifies the extension of the history-bookmark file
bookmark_ext = ".mpv.history",
-- use hash to bookmark_name
hash = true,
-- set false to get playlist from directory
use_playlist = true,
-- specifies a whitelist of files to find in a directory
whitelist = "3gp,amr,amv,asf,avi,avi,bdmv,f4v,flv,m2ts,m4v,mkv,mov,mp4,mpeg,mpg,ogv,rm,rmvb,ts,vob,webm,wmv",
-- excluded directories for shared, #windows: ["X:", "Z:", "F:/Download/", "Download"]
excluded_dir = [[
[]
]],
included_dir = [[
[]
]]
}
options.read_options(o, _, function() end)
o.excluded_dir = utils.parse_json(o.excluded_dir)
o.included_dir = utils.parse_json(o.included_dir)
local file_loaded = false
local locals = {
['eng'] = {
msg1 = 'Resume successfully',
msg2 = 'Resume the last played file in current directory',
msg3 = 'Press 1 to confirm, 0 to cancel',
},
['chs'] = {
msg1 = '成功恢复上次播放',
msg2 = '是否恢复当前目录的上次播放文件',
msg3 = '按1确认按0取消',
}
}
-- apply lang opts
local texts = locals[o.language]
-- `pl` stands for playlist
local path = nil
local dir = nil
local fname = nil
local pl_count = 0
local pl_name = nil
local pl_path = nil
local pl_list = {}
local pl_idx = 1
local current_idx = 1
local bookmark_path = nil
local history_dir = nil
local normalize_path = nil
local wait_msg
local on_key = false
if o.history_dir:find('^/:dir%%mpvconf%%') then
history_dir = o.history_dir:gsub('/:dir%%mpvconf%%', mp.find_config_file('.'))
elseif o.history_dir:find('^/:dir%%script%%') then
history_dir = o.history_dir:gsub('/:dir%%script%%', mp.find_config_file('scripts'))
elseif o.history_dir:find('/:var%%(.*)%%') then
local os_variable = o.history_dir:match('/:var%%(.*)%%')
history_dir = o.history_dir:gsub('/:var%%(.*)%%', os.getenv(os_variable))
else
history_dir = mp.command_native({ "expand-path", o.history_dir }) -- Expands both ~ and ~~
end
local is_windows = package.config:sub(1, 1) == "\\" -- detect path separator, detect path separator, windows uses backslashes
--create history_dir if it doesn't exist
if history_dir ~= '' then
local meta = utils.file_info(history_dir)
if not meta or not meta.is_dir then
local windows_args = { 'powershell', '-NoProfile', '-Command', 'mkdir', string.format("\"%s\"", history_dir) }
local unix_args = { 'mkdir', '-p', history_dir }
local args = is_windows and windows_args or unix_args
local res = mp.command_native({ name = "subprocess", capture_stdout = true, playback_only = false, args = args })
if res.status ~= 0 then
msg.error("Failed to create history_dir save directory " .. history_dir ..
". Error: " .. (res.error or "unknown"))
return
end
end
end
local function split(input)
local ret = {}
for str in string.gmatch(input, "([^,]+)") do
ret[#ret + 1] = str
end
return ret
end
local ext_whitelist = split(o.whitelist)
local function exclude(extension)
if #ext_whitelist > 0 then
for _, ext in pairs(ext_whitelist) do
if extension == ext then
return true
end
end
else
return
end
end
local function is_protocol(path)
return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
end
local function need_ignore(tab, val)
for _, element in pairs(tab) do
if string.find(val, element) then
return true
end
end
return false
end
local function tablelength(tab)
local count = 0
for _, _ in pairs(tab) do
count = count + 1
end
return count
end
local message_overlay = mp.create_osd_overlay('ass-events')
local message_timer = mp.add_timeout(1, function ()
message_overlay:remove()
end, true)
function show_message(text, time)
message_timer:kill()
message_timer.timeout = time or 1
message_overlay.data = text
message_overlay:update()
message_timer:resume()
end
local function normalize(path)
if normalize_path ~= nil then
if normalize_path then
path = mp.command_native({"normalize-path", path})
else
local directory = mp.get_property("working-directory", "")
path = utils.join_path(directory, path:gsub('^%.[\\/]',''))
if is_windows then path = path:gsub("\\", "/") end
end
return path
end
normalize_path = false
local commands = mp.get_property_native("command-list", {})
for _, command in ipairs(commands) do
if command.name == "normalize-path" then
normalize_path = true
break
end
end
return normalize(path)
end
function refresh_globals()
path = mp.get_property("path")
fname = mp.get_property("filename")
pl_count = mp.get_property_number('playlist-count', 0)
if path and not is_protocol(path) then
path = normalize(path)
dir = utils.split_path(path)
else
dir = nil
end
end
-- for unix use only
-- returns a table of command path and varargs, or nil if command was not found
local function command_exists(command, ...)
msg.debug("looking for command:", command)
-- msg.debug("args:", )
local process = mp.command_native({
name = "subprocess",
capture_stdout = true,
capture_stderr = true,
playback_only = false,
args = {"sh", "-c", "command -v -- " .. command}
})
if process.status == 0 then
local command_path = process.stdout:gsub("\n", "")
msg.debug("command found:", command_path)
return {command_path, ...}
else
msg.debug("command not found:", command)
return nil
end
end
-- returns md5 hash of the full path of the current media file
local function hash(path)
if path == nil then
msg.debug("something is wrong with the path, can't get full_path, can't hash it")
return
end
msg.debug("hashing:", path)
local cmd = {
name = 'subprocess',
capture_stdout = true,
playback_only = false,
}
local args = nil
local is_unix = package.config:sub(1,1) == "/"
if is_unix then
local md5 = command_exists("md5sum") or command_exists("md5") or command_exists("openssl", "md5 | cut -d ' ' -f 2")
if md5 == nil then
msg.warn("no md5 command found, can't generate hash")
return
end
md5 = table.concat(md5, " ")
cmd["stdin_data"] = path
args = {"sh", "-c", md5 .. " | cut -d ' ' -f 1 | tr '[:lower:]' '[:upper:]'" }
else --windows
-- https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-filehash?view=powershell-7.3
local hash_command = [[
$s = [System.IO.MemoryStream]::new();
$w = [System.IO.StreamWriter]::new($s);
$w.write(']] .. path .. [[');
$w.Flush();
$s.Position = 0;
Get-FileHash -Algorithm MD5 -InputStream $s | Select-Object -ExpandProperty Hash
]]
args = {"powershell", "-NoProfile", "-Command", hash_command}
end
cmd["args"] = args
msg.debug("hash cmd:", utils.to_string(cmd))
local process = mp.command_native(cmd)
if process.status == 0 then
local hash = process.stdout:gsub("%s+", "")
msg.debug("hash:", hash)
return hash
else
msg.warn("hash function failed")
return
end
end
local function get_bookmark_path(dir)
local fpath = string.sub(dir, 1, -2)
local _, name = utils.split_path(fpath)
local history_name = nil
if o.hash then
history_name = hash(dir)
if history_name == nil then
msg.warn("hash function failed, fallback to dirname")
history_name = name
end
else
history_name = name
end
local bookmark_name = history_name .. o.bookmark_ext
bookmark_path = utils.join_path(history_dir, bookmark_name)
if is_windows then bookmark_path = bookmark_path:gsub("\\", "/") end
end
local function file_exist(path)
local meta = utils.file_info(path)
if not meta or not meta.is_file then
return false
end
return true
end
-- get the content of the bookmark
-- Arg: bookmark_file (path)
-- Return: nil / content of the bookmark
local function get_record(bookmark_path)
local file = io.open(bookmark_path, 'r')
local record = file:read()
if record == nil then
msg.verbose('No history record is found in the bookmark file.')
return nil
end
msg.verbose('last play: ' .. record)
file:close()
return record
end
----- winapi start -----
-- in windows system, we can use the sorting function provided by the win32 API
-- see https://learn.microsoft.com/en-us/windows/win32/api/shlwapi/nf-shlwapi-strcmplogicalw
-- this function was taken from https://github.com/mpvnet-player/mpv.net/issues/575#issuecomment-1817413401
local winapi = {}
local is_windows = mp.get_property_native("platform") == "windows"
if is_windows then
-- is_ffi_loaded is false usually means the mpv builds without luajit
local is_ffi_loaded, ffi = pcall(require, "ffi")
if is_ffi_loaded then
winapi = {
ffi = ffi,
C = ffi.C,
CP_UTF8 = 65001,
shlwapi = ffi.load("shlwapi"),
}
-- ffi code from https://github.com/po5/thumbfast, Mozilla Public License Version 2.0
ffi.cdef[[
int __stdcall MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr,
int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar);
int __stdcall StrCmpLogicalW(wchar_t *psz1, wchar_t *psz2);
]]
winapi.utf8_to_wide = function(utf8_str)
if utf8_str then
local utf16_len = winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, nil, 0)
if utf16_len > 0 then
local utf16_str = winapi.ffi.new("wchar_t[?]", utf16_len)
if winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, utf16_str, utf16_len) > 0 then
return utf16_str
end
end
end
return ""
end
end
end
----- winapi end -----
local function alphanumsort_windows(filenames)
table.sort(filenames, function(a, b)
local a_wide = winapi.utf8_to_wide(a)
local b_wide = winapi.utf8_to_wide(b)
return winapi.shlwapi.StrCmpLogicalW(a_wide, b_wide) == -1
end)
return filenames
end
-- alphanum sorting for humans in Lua
-- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
local function alphanumsort_lua(filenames)
local function padnum(n, d)
return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d))
or ("%03d%s"):format(#n, n)
end
local tuples = {}
for i, f in ipairs(filenames) do
tuples[i] = {f:lower():gsub("0*(%d+)%.?(%d*)", padnum), f}
end
table.sort(tuples, function(a, b)
return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
end)
for i, tuple in ipairs(tuples) do filenames[i] = tuple[2] end
return filenames
end
local function alphanumsort(filenames)
local is_ffi_loaded = pcall(require, "ffi")
if is_windows and is_ffi_loaded then
alphanumsort_windows(filenames)
else
alphanumsort_lua(filenames)
end
end
local function create_playlist(dir)
local pl_list = {}
local file_list = utils.readdir(dir, 'files')
for i = 1, #file_list do
local file = file_list[i]
local ext = file:match('%.([^./]+)$')
if ext and exclude(ext:lower()) then
table.insert(pl_list, file)
msg.verbose("Adding " .. file)
end
end
alphanumsort(pl_list)
return pl_list
end
local function get_playlist()
local pl_list = {}
local playlist = mp.get_property_native("playlist")
for i = 0, #playlist - 1 do
local filename = mp.get_property("playlist/" .. i .. "/filename")
local _, file = utils.split_path(filename)
table.insert(pl_list, file)
end
return pl_list
end
-- get the index of the wanted file playlist
-- if there is no playlist, return nil
local function get_playlist_idx(dst_file)
if dst_file == nil or dst_file == " " then
return nil
end
local idx = nil
for i = 1, #pl_list do
if (dst_file == pl_list[i]) then
idx = i
return idx
end
end
return idx
end
local function jump_resume()
mp.unregister_event(jump_resume)
show_message(texts.msg1, 2)
end
local function unbind_key()
msg.verbose('Unbinding keys')
wait_jump_timer:kill()
mp.remove_key_binding('key_jump')
mp.remove_key_binding('key_cancel')
end
local function key_jump()
on_key = true
wait_jump_timer:kill()
unbind_key()
current_idx = pl_idx
mp.register_event('file-loaded', jump_resume)
msg.verbose('Jumping to ' .. pl_path)
mp.commandv('loadfile', pl_path)
end
local function key_cancel()
on_key = true
wait_jump_timer:kill()
unbind_key()
end
local function bind_key()
mp.add_forced_key_binding('1', 'key_jump', key_jump)
mp.add_forced_key_binding('0', 'key_cancel', key_cancel)
end
-- creat a .history file
local function record_history()
if not o.enabled or not file_loaded then return end
refresh_globals()
if not path or is_protocol(path) then return end
get_bookmark_path(dir)
local eof = mp.get_property_bool("eof-reached")
local percent_pos = mp.get_property_number("percent-pos", 0)
if not eof and percent_pos < 90 then
if fname ~= nil then
local file = io.open(bookmark_path, "w")
file:write(fname .. "\n")
file:close()
end
else
local file = io.open(bookmark_path, "w")
file:write(" " .. "\n")
file:close()
end
end
local timeout = o.timeout
local function wait_jumping()
timeout = timeout - 1
if timeout > 0 then
if not on_key then
local msg = string.format("%s -- %s? (%s) %02d", wait_msg, texts.msg2, texts.msg3, timeout)
show_message(msg, 1)
bind_key()
else
timeout = 0
wait_jump_timer:kill()
unbind_key()
end
else
wait_jump_timer:kill()
unbind_key()
end
end
-- record the file name when video is paused
-- and stop the timer
local function pause(_, paused)
if paused then
timer4saving_history:stop()
record_history()
else
timer4saving_history:resume()
end
end
-- main function of the file
local function record()
if not o.enabled then return end
refresh_globals()
if pl_count and pl_count < 1 then return end
if not path or is_protocol(path) or not file_exist(path) then return end
if not dir or not fname then return end
get_bookmark_path(dir)
included_dir_count = tablelength(o.included_dir)
if included_dir_count > 0 then
if not need_ignore(o.included_dir, dir) then return end
end
if need_ignore(o.excluded_dir, dir) then return end
msg.verbose('folder -- ' .. dir)
msg.verbose('playing -- ' .. fname)
msg.verbose('bookmark path -- ' .. bookmark_path)
if (not file_exist(bookmark_path)) then
pl_name = nil
return
else
pl_name = get_record(bookmark_path)
if pl_name then
pl_path = utils.join_path(dir, pl_name)
else
pl_name = fname
pl_path = path
end
end
if o.use_playlist or pl_count > 1 then
pl_list = get_playlist()
else
pl_list = create_playlist(dir)
end
pl_idx = get_playlist_idx(pl_name)
if (pl_idx == nil) then
msg.verbose('Playlist not found. Creating a new one...')
else
msg.verbose('playlist index --' .. pl_idx)
end
current_idx = get_playlist_idx(fname)
if current_idx then msg.verbose('current index -- ' .. current_idx) end
if current_idx and (pl_idx == nil) then
pl_idx = current_idx
pl_name = fname
pl_path = path
elseif current_idx and (pl_idx ~= current_idx) then
wait_msg = pl_idx
msg.verbose('Last watched episode -- ' .. wait_msg)
wait_jump_timer = mp.add_periodic_timer(1, wait_jumping)
end
timer4saving_history = mp.add_periodic_timer(o.save_period, record_history)
mp.observe_property("pause", "bool", pause)
end
mp.register_event('file-loaded', function()
file_loaded = true
local path = mp.get_property("path")
if not is_protocol(path) then
path = normalize(path)
directory = utils.split_path(path)
else
directory = nil
end
if directory ~= nil and directory ~= dir then
mp.add_timeout(0.5, record)
end
end)
mp.add_hook("on_unload", 50, function()
mp.unobserve_property(pause)
record_history()
file_loaded = false
end)