Skip to content

Instantly share code, notes, and snippets.

@HedioKojima
Last active July 2, 2026 02:57
Show Gist options
  • Select an option

  • Save HedioKojima/fdbfdd73570650b01c809afb5ae7829b to your computer and use it in GitHub Desktop.

Select an option

Save HedioKojima/fdbfdd73570650b01c809afb5ae7829b to your computer and use it in GitHub Desktop.
mpv WebDAV lua 由Qwen3.7-Max编写的osd版 功能灵感来自https://github.com/Ladersbt/mpv-config/blob/master/scripts/uosc_webdav.lua 🖖
local mp = require('mp')
local msg = require('mp.msg')
local options = require('mp.options')
local utils = require('mp.utils')
local commandv = mp.commandv
local command_native_async = mp.command_native_async
local set_property = mp.set_property
local get_property = mp.get_property
local osd_message = mp.osd_message
-- ================= 默认配置 =================
local opts = {
url = "http://192.168.1.100:8080/dav/",
user = "admin",
pass = "password",
max_lines = 15,
font_size = 32,
color_normal = "FFFFFF",
color_active = "0CD8FF",
header_color = "AAAAAA",
delete_mode_color = "FF5555",
video_exts = "mp4,mkv,avi,mov,wmv,flv,webm,m2ts,ts,rmvb,m4v,iso,vob",
sub_exts = "srt,ass,ssa,vtt,sup,sub,idx,smi,lrc",
slang = "chs,sc,zh-hans,zh-cn,jptc,cht,tc,zh-hant,zh-hk,zh-tw,chi,zho,zh,eng,en",
cache_enabled = true,
append_following_files = true,
video_only = true,
sync_playlist_sort = false,
}
options.read_options(opts, "webdav")
set_property("slang", opts.slang)
-- ================= 工具函数 =================
local function normalize_url(url)
if not url or url == "" then return "" end
if not url:match("/$") then url = url .. "/" end
return url
end
local function split_csv(s)
local ret = {}
for part in tostring(s or ""):gmatch("([^,%s]+)") do ret[#ret + 1] = part:lower() end
return ret
end
local function url_encode(str)
if not str then return "" end
return str:gsub("([^%w%-%.%_%~])", function(c) return string.format("%%%02X", string.byte(c)) end)
end
local function url_decode(str)
if not str then return "" end
return str:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
end
local function encode_webdav_path(path)
local decoded = url_decode(path)
return decoded:gsub("([^%w%-%.%_%~/])", function(c) return string.format("%%%02X", string.byte(c)) end)
end
local function xml_decode(str)
if not str then return "" end
return str:gsub("&amp;", "&"):gsub("&lt;", "<"):gsub("&gt;", ">"):gsub("&quot;", '"'):gsub("&apos;", "'")
end
local function ass_escape(str)
if not str then return "" end
return str:gsub("\\", "\\\\"):gsub("{", "\\{"):gsub("}", "\\}")
end
local function utf8_chars(s)
local chars = {}
if not s then return chars end
for char in s:gmatch("[%z\1-\127\194-\244][\128-\191]*") do chars[#chars + 1] = char end
return chars
end
local function truncate_middle(s, max_chars)
if not s then return "" end
local chars = utf8_chars(s)
if #chars <= max_chars then return s end
if max_chars <= 3 then return "..." end
local head = math.floor((max_chars - 3) / 2)
local tail = max_chars - 3 - head
local res = {}
for i = 1, head do res[#res + 1] = chars[i] end
res[#res + 1] = "..."
for i = #chars - tail + 1, #chars do res[#res + 1] = chars[i] end
return table.concat(res)
end
local function utf8_backspace(s)
if not s or s == "" then return "" end
return s:match("^(.-)[%z\1-\127\194-\244][\128-\191]*$") or ""
end
local function natural_compare(a, b)
local function split(s)
local t = {}
for text, num in s:lower():gmatch("(%D*)(%d*)") do
if text ~= "" then t[#t + 1] = text end
if num ~= "" then t[#t + 1] = tonumber(num) end
end
return t
end
local ta, tb = split(a), split(b)
for i = 1, math.max(#ta, #tb) do
local va, vb = ta[i], tb[i]
if va == nil then return true end
if vb == nil then return false end
if type(va) ~= type(vb) then return tostring(va) < tostring(vb) end
if va ~= vb then return va < vb end
end
return false
end
local month_map = { Jan=1, Feb=2, Mar=3, Apr=4, May=5, Jun=6, Jul=7, Aug=8, Sep=9, Oct=10, Nov=11, Dec=12 }
local function parse_lastmod(s)
if not s or s == "" then return 0 end
local day, mon, year, h, m = s:match("(%d+)%s+(%a+)%s+(%d+)%s+(%d+):(%d+):")
if not day then return 0 end
return (tonumber(year) or 0) * 100000000 + (month_map[mon] or 0) * 1000000 + tonumber(day) * 10000 + tonumber(h) * 100 + tonumber(m)
end
local function wipe(t) for k in pairs(t) do t[k] = nil end end
-- ================= 初始化常量 =================
opts.url = normalize_url(opts.url)
local protocol, domain = opts.url:match("^(https?://)([^/]+)")
if not protocol or not domain then msg.error("webdav.lua: opts.url 格式错误"); return end
local auth_prefix = string.format("%s%s:%s@%s", protocol, url_encode(opts.user), url_encode(opts.pass), domain)
local video_exts = {}
for _, ext in ipairs(split_csv(opts.video_exts)) do video_exts[ext] = true end
local sub_exts = {}
for _, ext in ipairs(split_csv(opts.sub_exts)) do sub_exts[ext] = true end
-- ================= 状态管理 =================
local overlay = mp.create_osd_overlay("ass-events")
local is_menu_open = false
local current_items = {}
local filtered_items = {}
local cursor_pos = 1
local current_loaded_url = ""
local last_visited_url = opts.url
local search_pattern = ""
local active_bindings = {}
local dir_cache = {}
local request_serial = 0
local sub_map = {}
local is_delete_mode = false
local selected_items = {}
local dir_sort = {}
local dir_cursor = {}
local sort_labels = { name_asc = "名称 A→Z", name_desc = "名称 Z→A", time_desc = "时间 新→旧", time_asc = "时间 旧→新" }
-- 核心控制函数前向声明 (彻底解决 Lua 循环依赖与 nil value 陷阱)
local close_menu, toggle_menu
local enter_search_mode, exit_search_mode
local bind_nav_mode, bind_search_mode
local is_search_mode = false
local function get_sort_mode() return dir_sort[current_loaded_url] or "name_asc" end
local function set_sort_mode(m) dir_sort[current_loaded_url] = m end
local function save_cursor_for_dir() if current_loaded_url ~= "" then dir_cursor[current_loaded_url] = cursor_pos end end
-- ================= 数据处理 =================
local function apply_sort()
local m = get_sort_mode()
table.sort(current_items, function(a, b)
local pa = a.type == "up" and 1 or (a.type == "dir" and 2 or 3)
local pb = b.type == "up" and 1 or (b.type == "dir" and 2 or 3)
if pa ~= pb then return pa < pb end
if a.type == "up" then return false end
if m == "name_desc" then return natural_compare(b.name, a.name)
elseif m == "time_desc" then
local ta, tb = parse_lastmod(a.lastmod), parse_lastmod(b.lastmod)
if ta ~= tb then return ta > tb end; return natural_compare(a.name, b.name)
elseif m == "time_asc" then
local ta, tb = parse_lastmod(a.lastmod), parse_lastmod(b.lastmod)
if ta ~= tb then return ta < tb end; return natural_compare(a.name, b.name)
else return natural_compare(a.name, b.name) end
end)
end
local function apply_filter()
wipe(filtered_items)
local p = search_pattern:lower()
for _, v in ipairs(current_items) do
if v.type == "dir" or v.type == "up" or not opts.video_only or v.is_video then
if p == "" or v.name:lower():find(p, 1, true) then filtered_items[#filtered_items + 1] = v end
end
end
if #filtered_items == 0 then cursor_pos = 1 else cursor_pos = math.max(1, math.min(cursor_pos, #filtered_items)) end
end
local function parse_and_build(stdout, target_url)
local items = {}
local target_path = target_url:match("https?://[^/]+(/.*)") or "/"
local norm_current = url_decode(target_path):gsub("/+$", "")
if target_url ~= opts.url then items[#items + 1] = { type = "up", title = "↩️ .. (返回上一层)", name = ".." } end
stdout = stdout:gsub("\r", "")
for block in stdout:gmatch("<[^>]*[Rr]esponse[^>]*>(.-)</[^>]*[Rr]esponse>") do
local raw_href = block:match("<[^>]*[Hh]ref[^>]*>([^<]+)</[^>]*[Hh]ref>")
if raw_href then
local lastmod_str = block:match("<[^>]*[Gg]etlastmodified[^>]*>([^<]+)</[^>]*[Gg]etlastmodified>") or ""
raw_href = xml_decode(raw_href)
local href_path = raw_href:match("https?://[^/]+(/.*)") or raw_href
local decoded_href = url_decode(href_path)
local norm_decoded = decoded_href:gsub("/+$", "")
if norm_decoded ~= norm_current then
local is_dir = block:match("<[^>]*[Rr]esourcetype[^>]*>.-<[Cc]ollection") ~= nil
if not is_dir then is_dir = raw_href:sub(-1) == "/" end
local name = decoded_href:match("([^/]+)/?$") or decoded_href
if name == "" then name = "/" end
local safe_url = encode_webdav_path(raw_href)
if is_dir then
items[#items + 1] = { type = "dir", name = name, title = "📁 " .. name, url = protocol .. domain .. safe_url, delete_url = protocol .. domain .. safe_url, lastmod = lastmod_str }
else
local ext = name:match("%.([^%.]+)$")
local ext_lower = ext and ext:lower()
items[#items + 1] = { type = "file", name = name, title = (ext_lower and video_exts[ext_lower] and "🎬 " or "📄 ") .. name, play_url = auth_prefix .. safe_url, delete_url = protocol .. domain .. safe_url, lastmod = lastmod_str, is_video = ext_lower and video_exts[ext_lower], is_sub = ext_lower and sub_exts[ext_lower] }
end
end
end
end
apply_sort()
return items
end
local function build_sub_map(items)
wipe(sub_map)
local subs = {}
for _, item in ipairs(items) do
if item.type == "file" and item.is_sub then
local stem = (item.name:match("^(.+)%.[^%.]+$") or item.name):lower()
subs[#subs + 1] = { stem = stem, url = item.play_url, title = item.name }
end
end
for _, item in ipairs(items) do
if item.type == "file" and item.is_video then
local vstem = (item.name:match("^(.+)%.[^%.]+$") or item.name):lower()
local matched = {}
for _, sub in ipairs(subs) do
if sub.stem:find(vstem, 1, true) then matched[#matched + 1] = { url = sub.url, title = sub.title } end
end
if #matched > 0 then sub_map[item.play_url] = matched end
end
end
end
-- ================= UI 渲染 (集成截断与防遮挡布局) =================
local function render_menu()
if not is_menu_open then overlay:remove(); return end
if #filtered_items == 0 then
overlay.data = string.format("{\\q2\\fs%d\\c&H%s&}WebDAV: 目录为空或正在加载...", opts.font_size, opts.header_color)
overlay:update(); return
end
-- 【调整】路径截断限制为 45
local path_text = url_decode((current_loaded_url:match("https?://[^/]+(/.*)") or "/"))
local display_path = truncate_middle(path_text, 45)
local title = string.format("{\\q2\\fs%d\\c&H%s&}WebDAV: %s [%s]", opts.font_size, opts.header_color, ass_escape(display_path), sort_labels[get_sort_mode()])
if is_search_mode then
title = title .. string.format(" {\\c&H00FFFF&}🔍 [Search: %s_]", ass_escape(search_pattern))
elseif search_pattern ~= "" then
title = title .. string.format(" {\\c&H00FFFF&}[Filter: %s]", ass_escape(search_pattern))
else
title = title .. string.format(" {\\c&H888888&}[/ 搜索 | s 排序 | d 删除]")
end
local start_idx = math.max(1, cursor_pos - math.floor(opts.max_lines / 2))
local end_idx = math.min(#filtered_items, start_idx + opts.max_lines - 1)
if end_idx - start_idx < opts.max_lines - 1 and #filtered_items >= opts.max_lines then start_idx = math.max(1, end_idx - opts.max_lines + 1) end
-- 【调整】顶部强制预留 4 行空白,彻底防止 mpv 核心 OSD 遮挡标题和列表
local lines = { "\\N", "\\N", "\\N", "\\N", title, "\\N" }
for i = start_idx, end_idx do
local item = filtered_items[i]
local is_selected = is_delete_mode and selected_items[item.delete_url]
local prefix = is_delete_mode and (is_selected and "▶☑ " or "☐ ") or ((i == cursor_pos) and "● " or " ")
local color = (i == cursor_pos or is_selected) and (is_delete_mode and opts.delete_mode_color or opts.color_active) or opts.color_normal
local icon = ""
if item.type == "up" then icon = "↩️ "
elseif item.type == "dir" then icon = "📁 "
elseif item.is_video then icon = "🎬 "
else icon = "📄 " end
-- 【调整】文件名截断限制放宽至 90
local truncated_name = truncate_middle(item.name, 90)
local display_name = ""
if search_pattern ~= "" and item.type ~= "up" then
local p = search_pattern:lower()
local name_lower = truncated_name:lower()
local s, e = name_lower:find(p, 1, true)
if s then
local part1 = ass_escape(truncated_name:sub(1, s-1))
local part2 = ass_escape(truncated_name:sub(s, e))
local part3 = ass_escape(truncated_name:sub(e+1))
display_name = ass_escape(icon) .. part1 .. "{\\c&H00FFFF&}" .. part2 .. "{\\c&H" .. color .. "&}" .. part3
else
display_name = ass_escape(icon) .. ass_escape(truncated_name)
end
else
display_name = ass_escape(icon) .. ass_escape(truncated_name)
end
lines[#lines + 1] = string.format("{\\c&H%s&}%s%s\\N", color, prefix, display_name)
end
overlay.data = table.concat(lines)
overlay:update()
end
-- ================= 异步网络请求 =================
local function open_webdav_url(target_url, force_refresh)
target_url = normalize_url(target_url)
if target_url == "" then return end
save_cursor_for_dir()
current_loaded_url = target_url
last_visited_url = target_url
is_delete_mode = false
search_pattern = ""
wipe(selected_items)
if opts.cache_enabled and dir_cache[target_url] and not force_refresh then
current_items = {}; for _, v in ipairs(dir_cache[target_url]) do current_items[#current_items + 1] = v end
cursor_pos = dir_cursor[target_url] or 1
build_sub_map(current_items)
apply_sort(); apply_filter(); render_menu()
return
end
osd_message("正在获取列表...", 1)
render_menu()
request_serial = request_serial + 1
local serial = request_serial
command_native_async({
name = "subprocess", playback_only = false, capture_stdout = true,
args = { "curl", "-s", "-k", "-X", "PROPFIND", "-u", opts.user .. ":" .. opts.pass, "-H", "Depth: 1", target_url }
}, function(success, result, err)
if not is_menu_open or serial ~= request_serial then return end
if not success or not result or result.status ~= 0 then osd_message("连接失败", 3); return end
current_items = parse_and_build(result.stdout or "", target_url)
build_sub_map(current_items)
if opts.cache_enabled then
dir_cache[target_url] = {}
for _, v in ipairs(current_items) do dir_cache[target_url][#dir_cache[target_url] + 1] = v end
end
cursor_pos = dir_cursor[target_url] or 1
apply_filter(); render_menu()
end)
end
-- ================= 播放逻辑 =================
local function play_file_with_playlist(item)
if not opts.append_following_files then
commandv("loadfile", item.play_url, "replace")
osd_message("🎬 播放单文件 (未加载列表)", 2)
return
end
local playable = {}
for _, v in ipairs(current_items) do
if v.type == "file" and (not opts.video_only or v.is_video) then playable[#playable + 1] = v end
end
if not opts.sync_playlist_sort then table.sort(playable, function(a, b) return natural_compare(a.name, b.name) end) end
if #playable == 0 then return end
local target_pos = 0
for i, v in ipairs(playable) do if v.play_url == item.play_url then target_pos = i - 1; break end end
commandv("loadfile", playable[1].play_url, "replace")
for i = 2, #playable do commandv("loadfile", playable[i].play_url, "append") end
commandv("playlist-play-index", target_pos)
local mode_hint = opts.sync_playlist_sort and ("继承: " .. sort_labels[get_sort_mode()]) or "名称 A→Z"
osd_message(string.format("🎬 播放列表 %d 个视频 [%s]", #playable, mode_hint), 3)
end
mp.register_event("file-loaded", function()
local path = get_property("path")
if path and sub_map[path] then
for _, sub in ipairs(sub_map[path]) do commandv("sub-add", sub.url, "auto", sub.title) end
end
end)
-- ================= 删除核心逻辑 =================
local function execute_delete_queue(queue, index, total, callback)
if index > total then callback(true); return end
local item = queue[index]
local target_url = item.delete_url
if item.type == "dir" and not target_url:match("/$") then target_url = target_url .. "/" end
osd_message(string.format("🗑️ 正在删除 %d/%d: %s", index, total, item.name), 1)
command_native_async({
name = "subprocess", playback_only = false,
args = { "curl", "-s", "-k", "-X", "DELETE", "-u", opts.user .. ":" .. opts.pass, target_url }
}, function(success, result, err)
if not success or not result or result.status ~= 0 then
osd_message("❌ 删除失败: " .. item.name, 3)
callback(false); return
end
execute_delete_queue(queue, index + 1, total, callback)
end)
end
local function toggle_delete_mode()
is_delete_mode = not is_delete_mode
wipe(selected_items)
osd_message(is_delete_mode and "🗑️ 删除模式 (Space选中, A全选, X执行, ESC退出)" or "退出删除模式", 2)
render_menu()
end
local function toggle_select_item()
if not is_delete_mode then return end
local item = filtered_items[cursor_pos]
if not item or item.type == "up" then return end
local key = item.delete_url
if selected_items[key] then selected_items[key] = nil else selected_items[key] = item end
render_menu()
end
local function select_all()
if not is_delete_mode then return end
local all_selected = true
for _, item in ipairs(filtered_items) do
if item.type ~= "up" and not selected_items[item.delete_url] then all_selected = false; break end
end
wipe(selected_items)
if not all_selected then
for _, item in ipairs(filtered_items) do
if item.type ~= "up" then selected_items[item.delete_url] = item end
end
end
render_menu()
end
local function confirm_and_delete()
if not is_delete_mode then return end
local queue = {}
for _, item in pairs(selected_items) do queue[#queue + 1] = item end
if #queue == 0 then osd_message("未选择任何文件", 1); return end
osd_message(string.format("⚠️ 确认删除 %d 个项目?(再次按 X 确认, ESC取消)", #queue), 5)
local confirm_bind = "wd-confirm-delete"
mp.add_forced_key_binding("x", confirm_bind, function()
mp.remove_key_binding(confirm_bind)
is_delete_mode = false; wipe(selected_items)
execute_delete_queue(queue, 1, #queue, function(success)
if success then osd_message("✅ 删除完成", 2); open_webdav_url(current_loaded_url, true)
else render_menu() end
end)
end)
mp.add_timeout(5, function() mp.remove_key_binding(confirm_bind) end)
end
-- ================= 搜索输入路由 =================
local function handle_search_input(input)
if input.event == "up" then return end
local key_text = input.key_text
if key_text and key_text ~= "" and key_text:byte(1) >= 32 then
search_pattern = search_pattern .. key_text
apply_filter()
render_menu()
end
end
local function backspace_search()
if search_pattern ~= "" then
search_pattern = utf8_backspace(search_pattern)
apply_filter()
render_menu()
else
exit_search_mode()
end
end
local function clear_search()
search_pattern = ""
apply_filter()
render_menu()
end
-- ================= 菜单控制 (赋值实现,彻底打通闭包上值) =================
close_menu = function()
is_menu_open = false; is_delete_mode = false; is_search_mode = false; wipe(selected_items)
request_serial = request_serial + 1
overlay:remove()
for _, name in ipairs(active_bindings) do mp.remove_key_binding(name) end
wipe(active_bindings)
end
-- ================= 核心:模态按键绑定系统 =================
local function bind(key, name, fn, opt)
mp.add_forced_key_binding(key, name, fn, opt)
active_bindings[#active_bindings + 1] = name
end
bind_nav_mode = function()
for _, name in ipairs(active_bindings) do mp.remove_key_binding(name) end
wipe(active_bindings)
bind("UP", "wd-up", function() cursor_pos = cursor_pos - 1; if cursor_pos < 1 then cursor_pos = #filtered_items end; render_menu() end, {repeatable=true})
bind("DOWN", "wd-down", function() cursor_pos = cursor_pos + 1; if cursor_pos > #filtered_items then cursor_pos = 1 end; render_menu() end, {repeatable=true})
bind("RIGHT", "wd-enter", function()
local item = filtered_items[cursor_pos]
if not item then return end
if is_delete_mode then toggle_select_item(); return end
if item.type == "dir" then save_cursor_for_dir(); open_webdav_url(item.url)
elseif item.type == "up" then open_webdav_url(current_loaded_url:match("^(.*/)[^/]+/?$") or opts.url)
elseif item.type == "file" then play_file_with_playlist(item); close_menu() end
end)
bind("ESC", "wd-esc", function()
if is_delete_mode then toggle_delete_mode() else close_menu() end
end)
bind("LEFT", "wd-back", function() open_webdav_url(current_loaded_url:match("^(.*/)[^/]+/?$") or opts.url) end)
bind("s", "wd-sort", function()
local modes = {"name_asc", "name_desc", "time_desc", "time_asc"}
local cur = get_sort_mode()
for i, m in ipairs(modes) do if m == cur then set_sort_mode(modes[(i % #modes) + 1]); break end end
apply_sort(); apply_filter(); render_menu()
osd_message("排序: " .. sort_labels[get_sort_mode()], 1)
end)
bind("d", "wd-del-mode", toggle_delete_mode)
bind("SPACE", "wd-select", toggle_select_item)
bind("a", "wd-select-all", select_all)
bind("x", "wd-exec-del", confirm_and_delete)
bind("/", "wd-enter-search", function() enter_search_mode() end)
end
bind_search_mode = function()
for _, name in ipairs(active_bindings) do mp.remove_key_binding(name) end
wipe(active_bindings)
bind("UP", "wd-up", function() cursor_pos = cursor_pos - 1; if cursor_pos < 1 then cursor_pos = #filtered_items end; render_menu() end, {repeatable=true})
bind("DOWN", "wd-down", function() cursor_pos = cursor_pos + 1; if cursor_pos > #filtered_items then cursor_pos = 1 end; render_menu() end, {repeatable=true})
bind("RIGHT", "wd-search-enter", function()
exit_search_mode()
local item = filtered_items[cursor_pos]
if not item then return end
if item.type == "dir" then save_cursor_for_dir(); open_webdav_url(item.url)
elseif item.type == "up" then open_webdav_url(current_loaded_url:match("^(.*/)[^/]+/?$") or opts.url)
elseif item.type == "file" then play_file_with_playlist(item); close_menu() end
end)
bind("ESC", "wd-search-esc", function() exit_search_mode() end)
bind("BS", "wd-search-bs", backspace_search, {repeatable=true})
bind("Ctrl+c", "wd-search-bs2", backspace_search, {repeatable=true})
bind("Ctrl+u", "wd-search-clear", clear_search)
bind("ANY_UNICODE", "wd-search-input", handle_search_input, {repeatable=true, complex=true})
end
enter_search_mode = function()
is_search_mode = true
bind_search_mode()
render_menu()
end
exit_search_mode = function()
is_search_mode = false
bind_nav_mode()
render_menu()
end
toggle_menu = function()
if is_menu_open then close_menu(); return end
is_menu_open = true
search_pattern = ""
bind_nav_mode()
open_webdav_url(last_visited_url)
end
mp.add_key_binding("w", "toggle-webdav", toggle_menu)
webdav.lua uosc版 可使用此处 https://github.com/Ladersbt/mpv-config/tree/master/scripts/uosc_webdav 🖖
@Ladersbt

Copy link
Copy Markdown

感谢你的推荐,我用ai写的这个webdav脚本很多是借鉴的你的😂,在你的基础上针对我个人的需求做了一些更改

@HedioKojima

Copy link
Copy Markdown
Author

您修改的很好,好多功能是我所想不到的,我让ai借用您许多新功能和思路重写了osd版的,感谢您的想法 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment