Created
May 28, 2026 06:39
-
-
Save itssoap/05956a95aa66086a0653fc352195661a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- 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("<", "<") | |
| s = s:gsub(">", ">") | |
| s = s:gsub(""", '"') | |
| s = s:gsub("'", "'") | |
| 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