Skip to content

Instantly share code, notes, and snippets.

@natyusha
Last active November 7, 2025 07:52
Show Gist options
  • Select an option

  • Save natyusha/2c33e54dce3a0136d39c10e1178c7d5b to your computer and use it in GitHub Desktop.

Select an option

Save natyusha/2c33e54dce3a0136d39c10e1178c7d5b to your computer and use it in GitHub Desktop.
Autoload external Matroska XML chapter files (.xml) for mpv
--[[
* 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