Skip to content

Instantly share code, notes, and snippets.

@itssoap
Created May 28, 2026 06:39
Show Gist options
  • Select an option

  • Save itssoap/05956a95aa66086a0653fc352195661a to your computer and use it in GitHub Desktop.

Select an option

Save itssoap/05956a95aa66086a0653fc352195661a to your computer and use it in GitHub Desktop.
-- webdav-browser.lua
-- MPV plugin to browse and play files from a WebDAV server
-- Trigger: Ctrl+j
-- Place this file in your mpv scripts folder (e.g., ~/.config/mpv/scripts/ or %APPDATA%/mpv/scripts/)
local mp = require("mp")
local utils = require("mp.utils")
local msg = require("mp.msg")
-- ============================================================
-- CONFIGURATION - Edit these values
-- ============================================================
local WEBDAV_URL = "https://"
local WEBDAV_USER = ""
local WEBDAV_PASS = ""
-- ============================================================
-- State
local current_path = ""
local entries = {}
local selected = 1
local browser_active = false
local overlay = mp.create_osd_overlay("ass-events")
local history = {} -- stack of {path, selected} for back navigation
local now_playing_path = nil -- full path of currently playing file
-- Media file extensions
local media_exts = {
mp4 = true, mkv = true, avi = true, mov = true, wmv = true, flv = true, webm = true,
mp3 = true, flac = true, ogg = true, wav = true, aac = true, m4a = true, opus = true,
ts = true, m2ts = true, mpg = true, mpeg = true, vob = true, rm = true, rmvb = true,
["3gp"] = true, m4v = true, ogv = true, wma = true,
}
local function is_media(name)
local ext = name:match("%.([^%.]+)$")
return ext and media_exts[ext:lower()]
end
-- Pure Lua base64 encoder (no external dependencies)
local b64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local function base64_encode(data)
local out = {}
for i = 1, #data, 3 do
local a, b, c = data:byte(i, i + 2)
b = b or 0
c = c or 0
local n = a * 65536 + b * 256 + c
local remaining = #data - i + 1
out[#out + 1] = b64_chars:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
out[#out + 1] = b64_chars:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
out[#out + 1] = remaining > 1 and b64_chars:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1) or "="
out[#out + 1] = remaining > 2 and b64_chars:sub(n % 64 + 1, n % 64 + 1) or "="
end
return table.concat(out)
end
-- Cache base64 auth header
local b64_auth_cache = nil
local function get_b64_auth()
if b64_auth_cache then return b64_auth_cache end
b64_auth_cache = base64_encode(WEBDAV_USER .. ":" .. WEBDAV_PASS)
return b64_auth_cache
end
local function xml_unescape(s)
s = s:gsub("&", "&")
s = s:gsub("&lt;", "<")
s = s:gsub("&gt;", ">")
s = s:gsub("&quot;", '"')
s = s:gsub("&apos;", "'")
return s
end
local function url_decode(s)
return s:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
end
local function url_encode_path(s)
-- Encode each path segment, preserving slashes
return s:gsub("([^/]+)", function(seg)
return seg:gsub("([^%w%-_.~])", function(c)
return string.format("%%%02X", string.byte(c))
end)
end)
end
local function build_url(path)
local base = WEBDAV_URL:gsub("/$", "")
if path == "" then
return base .. "/"
end
return base .. "/" .. url_encode_path(path)
end
-- Build a URL with credentials embedded for mpv playback
local function build_play_url(path)
local url = build_url(path)
-- Use --http-header-fields for auth instead of embedding in URL
return url
end
-- PROPFIND request via curl
local function webdav_list(path)
local url = build_url(path)
msg.info("Listing: " .. url)
local args = {
"curl", "-s", "-X", "PROPFIND",
"-u", WEBDAV_USER .. ":" .. WEBDAV_PASS,
"-H", "Depth: 1",
"-H", "Content-Type: application/xml",
"-d", '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:displayname/><d:getcontentlength/></d:prop></d:propfind>',
url
}
local result = utils.subprocess({ args = args, cancellable = false })
if result.status ~= 0 then
msg.error("curl failed: " .. (result.stderr or "unknown error"))
return nil
end
local xml = result.stdout
if not xml or xml == "" then
msg.error("Empty response from WebDAV server")
return nil
end
local items = {}
-- Parse each <d:response> block
for response in xml:gmatch("<[dD]:response>(.-)</[dD]:response>") do
local href = response:match("<[dD]:href>(.-)</[dD]:href>")
if href then
href = xml_unescape(href)
local is_dir = response:match("<[dD]:collection") ~= nil
local name = url_decode(href:match("([^/]+)/?$") or "")
-- Skip the current directory entry
local decoded_href = url_decode(href)
local current_url = url_decode(build_url(path)):gsub("https?://[^/]+", "")
if decoded_href:gsub("/$", "") ~= current_url:gsub("/$", "") and name ~= "" then
table.insert(items, {
name = name,
is_dir = is_dir,
href = href,
path = path ~= "" and (path:gsub("/$", "") .. "/" .. name) or name,
})
end
end
end
-- Sort: directories first, then alphabetical
table.sort(items, function(a, b)
if a.is_dir ~= b.is_dir then return a.is_dir end
return a.name:lower() < b.name:lower()
end)
return items
end
-- Render the browser overlay
local function render()
if not browser_active then
overlay:remove()
return
end
local ass = ""
local w, h = mp.get_osd_size()
if not w then w, h = 1920, 1080 end
-- Semi-transparent background
ass = ass .. string.format("{\\pos(0,0)\\1c&H000000&\\1a&H60&\\bord0\\shad0\\p1}m 0 0 l %d 0 l %d %d l 0 %d{\\p0}\n", w, w, h, h)
-- Title
local title = current_path == "" and "/" or ("/" .. current_path)
ass = ass .. string.format("{\\pos(40,20)\\fs24\\1c&HFFFFFF&\\1a&H00&\\bord2\\3c&H000000&\\b1}WebDAV: %s{\\b0}\n", title)
-- Items - scrolling window that keeps selected item visible
local visible_count = math.floor((h - 100) / 32)
local start = 1
if selected > visible_count then
start = selected - visible_count + 1
end
-- Clamp start so selected is always within the visible window
if selected < start then
start = selected
elseif selected >= start + visible_count then
start = selected - visible_count + 1
end
for i = start, math.min(#entries, start + visible_count - 1) do
local e = entries[i]
local y = 60 + (i - start) * 32
local is_playing = (now_playing_path and e.path == now_playing_path)
local prefix = e.is_dir and "📁 " or (is_playing and "▶ " or "🎬 ")
local color
if i == selected then
color = "\\1c&H00FFFF&"
elseif is_playing then
color = "\\1c&H00FF00&"
else
color = "\\1c&HCCCCCC&"
end
local highlight = (i == selected or is_playing) and "\\b1" or "\\b0"
local name_display = prefix .. e.name .. (e.is_dir and "/" or "")
local playing_tag = is_playing and " (playing)" or ""
ass = ass .. string.format("{\\pos(60,%d)\\fs22%s%s\\bord1\\3c&H000000&}%s%s{\\b0}\n", y, color, highlight, name_display, playing_tag)
end
if #entries == 0 then
ass = ass .. "{\\pos(60,60)\\fs22\\1c&HFFFF00&\\bord1\\3c&H000000&}(empty folder)\n"
end
-- Scroll indicator
if #entries > visible_count then
local pos_text = string.format("[%d/%d]", selected, #entries)
ass = ass .. string.format("{\\pos(%d,20)\\fs18\\1c&H999999&\\bord1\\3c&H000000&\\an3}%s\n", w - 40, pos_text)
end
-- Help bar
ass = ass .. string.format("{\\pos(40,%d)\\fs18\\1c&H999999&\\bord1\\3c&H000000&}↑/↓ Navigate | Enter: Open | Backspace: Back | A: Play All | Esc: Close\n", h - 30)
overlay.data = ass
overlay:update()
end
local function navigate(path, select_path)
mp.osd_message("Loading...", 30)
local items = webdav_list(path)
mp.osd_message("", 0)
if items then
current_path = path
entries = items
selected = 1
-- Auto-select a specific entry if requested
if select_path then
for i, e in ipairs(entries) do
if e.path == select_path then
selected = i
break
end
end
end
render()
else
mp.osd_message("Failed to load directory", 3)
end
end
local function play_file(path)
local url = build_url(path)
msg.info("Playing: " .. url)
now_playing_path = path
browser_active = false
overlay:remove()
mp.command_native({
"loadfile", url, "replace", -1,
"http-header-fields=[Authorization: Basic " .. get_b64_auth() .. "]"
})
end
local function play_all_from_selected()
-- Collect all media files from current entries starting at selected
local playlist = {}
for i = 1, #entries do
if not entries[i].is_dir and is_media(entries[i].name) then
table.insert(playlist, entries[i])
end
end
if #playlist == 0 then
mp.osd_message("No media files in this folder", 3)
return
end
browser_active = false
overlay:remove()
local auth_header = "Authorization: Basic " .. get_b64_auth()
for i, item in ipairs(playlist) do
local url = build_url(item.path)
local mode = (i == 1) and "replace" or "append-play"
mp.command_native({
"loadfile", url, mode, -1,
"http-header-fields=[" .. auth_header .. "]"
})
end
mp.osd_message(string.format("Queued %d files", #playlist), 3)
end
-- Key bindings (only active when browser is open)
local bindings_active = false
local unbind_keys -- forward declaration
local function bind_keys()
if bindings_active then return end
bindings_active = true
mp.add_forced_key_binding("UP", "webdav-up", function()
if selected > 1 then selected = selected - 1; render() end
end, { repeatable = true })
mp.add_forced_key_binding("DOWN", "webdav-down", function()
if selected < #entries then selected = selected + 1; render() end
end, { repeatable = true })
mp.add_forced_key_binding("ENTER", "webdav-enter", function()
if #entries == 0 then return end
local e = entries[selected]
if e.is_dir then
table.insert(history, { path = current_path, selected = selected })
navigate(e.path .. "/")
else
play_file(e.path)
unbind_keys()
end
end)
mp.add_forced_key_binding("BS", "webdav-back", function()
if #history > 0 then
local prev = table.remove(history)
current_path = prev.path
navigate(prev.path)
selected = prev.selected
render()
end
end)
mp.add_forced_key_binding("a", "webdav-play-all", function()
play_all_from_selected()
unbind_keys()
end)
mp.add_forced_key_binding("ESC", "webdav-close", function()
browser_active = false
overlay:remove()
unbind_keys()
end)
mp.add_forced_key_binding("PGUP", "webdav-pgup", function()
selected = math.max(1, selected - 10)
render()
end, { repeatable = true })
mp.add_forced_key_binding("PGDWN", "webdav-pgdown", function()
selected = math.min(#entries, selected + 10)
render()
end, { repeatable = true })
mp.add_forced_key_binding("HOME", "webdav-home", function()
selected = 1
render()
end)
mp.add_forced_key_binding("END", "webdav-end", function()
selected = #entries
render()
end)
end
unbind_keys = function()
if not bindings_active then return end
bindings_active = false
local keys = {"UP", "DOWN", "ENTER", "BS", "ESC", "a", "PGUP", "PGDWN", "HOME", "END"}
for _, k in ipairs(keys) do
mp.remove_key_binding("webdav-" .. k:lower())
end
-- Need to use the exact binding names
mp.remove_key_binding("webdav-up")
mp.remove_key_binding("webdav-down")
mp.remove_key_binding("webdav-enter")
mp.remove_key_binding("webdav-back")
mp.remove_key_binding("webdav-close")
mp.remove_key_binding("webdav-play-all")
mp.remove_key_binding("webdav-pgup")
mp.remove_key_binding("webdav-pgdown")
mp.remove_key_binding("webdav-home")
mp.remove_key_binding("webdav-end")
end
local function toggle_browser()
if browser_active then
browser_active = false
overlay:remove()
unbind_keys()
else
browser_active = true
-- If a file is playing, navigate to its parent folder and select it
if now_playing_path then
local parent = now_playing_path:match("^(.*/)")
if not parent then parent = "" end
-- Build history stack from root to parent so backspace works
history = {}
local segments = {}
for seg in parent:gmatch("([^/]+)") do
table.insert(segments, seg)
end
local accumulated = ""
for i = 1, #segments do
table.insert(history, { path = accumulated, selected = 1 })
accumulated = accumulated .. segments[i] .. "/"
end
bind_keys()
navigate(parent, now_playing_path)
else
history = {}
bind_keys()
navigate(current_path)
end
end
end
-- Main trigger: Ctrl+j
mp.add_key_binding("Ctrl+j", "webdav-browse", toggle_browser)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment