Last active
November 7, 2025 07:52
-
-
Save natyusha/2c33e54dce3a0136d39c10e1178c7d5b to your computer and use it in GitHub Desktop.
Autoload external Matroska XML chapter files (.xml) for mpv
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
| --[[ | |
| * external-chapters-xml.lua v.2025-11-07 | |
| * Autoload external Matroska XML chapter files (.xml) for mpv | |
| * Based on chapter-make-read.lua by dyphire | |
| * Author: natyusha | |
| * Link: https://gist.github.com/natyusha/2c33e54dce3a0136d39c10e1178c7d5b | |
| --]] | |
| local msg = require 'mp.msg' | |
| local utils = require 'mp.utils' | |
| local opt = require 'mp.options' | |
| -- Options | |
| local o = { | |
| prefer = true, | |
| autoload = true, | |
| osd_message = false, | |
| subfolder = 'chapters', -- set '' to disable | |
| } | |
| opt.read_options(o) | |
| -- Exclude URLs | |
| local function is_protocol(p) | |
| return type(p) == 'string' and (p:find('^%a[%w.+-]-://') or p:find('^%a[%w.+-]-:%?')) | |
| end | |
| -- Time Parser - HH:MM:SS.nnnnnnnnn | |
| local function parse_time(t) | |
| local h, m, s = t:match('(%d+):(%d+):(%d+%.?%d*)') | |
| if not h then return nil end | |
| s = s:gsub(',', '.') | |
| return tonumber(h) * 3600 + tonumber(m) * 60 + tonumber(s) | |
| end | |
| -- XML Parser | |
| local function read_xml(path) | |
| local f = io.open(path, 'rb') | |
| if not f then return nil end | |
| local data = f:read('*all'); f:close() | |
| data = data:gsub('<%?xml.-%?>',''):gsub('<!DOCTYPE.-%>',''):gsub('<!--.-%-->','') | |
| :gsub('\r',''):gsub('\n[%s]*\n','\n') | |
| local chapters = {} | |
| local in_atom = false | |
| local cur_time, cur_title = nil, nil | |
| for line in data:gmatch('[^\n]+') do | |
| line = line:gsub('^%s+',''):gsub('%s+$','') | |
| if not line:find('^<') then goto continue end | |
| if line:find('<ChapterAtom') then | |
| in_atom = true; cur_time, cur_title = nil, nil | |
| elseif line:find('</ChapterAtom>') and in_atom then | |
| if cur_time and cur_title then | |
| table.insert(chapters, {time = cur_time, title = cur_title}) | |
| end | |
| in_atom = false | |
| elseif in_atom then | |
| local ts = line:match('<ChapterTimeStart>([%d:%.%,]+)</ChapterTimeStart>') | |
| if ts then cur_time = parse_time(ts) end | |
| local title = line:match('<ChapterString>(.-)</ChapterString>') | |
| if title then cur_title = title end | |
| end | |
| ::continue:: | |
| end | |
| return #chapters > 0 and chapters or nil | |
| end | |
| -- Load Parsed XML | |
| local function load_xml() | |
| local path = mp.get_property('path') | |
| if not path or is_protocol(path) then return end | |
| local dir = utils.split_path(path) | |
| local base_name = mp.get_property('filename/no-ext') | |
| local xml_name = base_name .. '.xml' | |
| local search_dirs = { dir } | |
| if o.subfolder ~= '' then | |
| local sub = utils.join_path(dir, o.subfolder) | |
| if utils.file_info(sub) and utils.file_info(sub).is_dir then table.insert(search_dirs, 1, sub) end | |
| end | |
| for _, base in ipairs(search_dirs) do | |
| local full = utils.join_path(base, xml_name) | |
| local info = utils.file_info(full) | |
| if info and info.is_file then | |
| local chapters = read_xml(full) | |
| if chapters then | |
| table.sort(chapters, function(a,b) return a.time < b.time end) | |
| local mp_chapters = {} | |
| for i, chapter in ipairs(chapters) do | |
| mp_chapters[i] = { | |
| time = chapter.time, | |
| title = chapter.title ~= '' and chapter.title or ('Chapter ' .. string.format('%02d', i)) | |
| } | |
| end | |
| mp.set_property_native('chapter-list', mp_chapters) | |
| msg.info('External XML chapters loaded (' .. #mp_chapters .. ')') | |
| if o.osd_message then mp.osd_message('External XML chapters loaded (' .. #mp_chapters .. ')', 2) end | |
| return | |
| end | |
| end | |
| end | |
| end | |
| -- HOOKS ----------------------------------------------------------------------- | |
| if o.autoload then | |
| mp.add_hook('on_preloaded', 50, function() | |
| local internal_count = mp.get_property_number('chapter-list/count', 0) | |
| if o.prefer or internal_count == 0 then load_xml() end | |
| end) | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment