Files
2026-03-27 07:06:16 +01:00

429 lines
15 KiB
Lua

--[[
mpv-sub-select
This script allows you to configure advanced subtitle track selection based on
the current audio track and the names and language of the subtitle tracks.
https://github.com/CogentRedTester/mpv-sub-select
]]--
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local opt = require 'mp.options'
local o = {
--forcibly enable the script regardless of the sid option
force_enable = false,
--experimental audio track selection based on the preferences.
select_audio = false,
--observe audio switches and reselect the subtitles when alang changes
observe_audio_switches = false,
--only select forced subtitles if they are explicitly included in slang
explicit_forced_subs = false,
--the folder that contains the 'sub-select.json' file
config = "~~/script-opts"
}
opt.read_options(o, "sub_select")
local prefs
local ENABLED = o.force_enable or true
local latest_audio = {}
local audio_tracks = {}
local sub_tracks = {}
-- represents when there is no audio or subtitle track selected
local NO_TRACK = {
id = 0
}
--returns a table that stores the given table t as the __index in its metatable
--creates a prototypally inherited table
local function redirect_table(t, new)
return setmetatable(new or {}, { __index = t })
end
local function type_check(val, t, required)
if val == nil then return not required end
if not t:find(type(val)) then return false end
return true
end
local function setup_prefs()
local file = assert(io.open(mp.command_native({"expand-path", o.config .. "/sub-select.json"})))
local json = file:read("*all")
file:close()
prefs = utils.parse_json(json)
assert(prefs, "Invalid JSON format in sub-select.json.")
local reservedIDs = { ['^'] = true }
local IDs = {}
-- storing the ID in the first pass
for _, pref in ipairs(prefs) do
if pref.id then
assert(not reservedIDs[pref.id], 'using reserved ID '..pref.id)
assert(not IDs[pref.id], 'duplicate ID '..pref.id)
IDs[pref.id] = pref
end
end
-- doing a second pass to inherit prefs and type check
for i, pref in ipairs(prefs) do
local pref_str = 'pref_'..i..' '..utils.to_string(pref)
assert(type_check(pref.inherit, 'string'), '`inherit` must be a string: '..pref_str)
if pref.inherit then
local parent = pref.inherit == '^' and prefs[i-1] or IDs[pref.inherit]
assert(parent, 'failed to find matching id: '..pref_str)
pref = redirect_table(parent, pref)
end
-- type checking the options
assert(type_check(pref.alang, 'string table', true), '`alang` must be a string or a table of strings: '..pref_str)
assert(type_check(pref.slang, 'string table', true), '`slang` must be a string or a table of strings: '..pref_str)
assert(type_check(pref.blacklist, 'table'), '`blacklist` must be a table: '..pref_str)
assert(type_check(pref.whitelist, 'table'), '`whitelist` must be a table: '..pref_str)
assert(type_check(pref.condition, 'string'), '`condition` must be a string: '..pref_str)
assert(type_check(pref.id, 'string'), '`id` must be a string: '..pref_str)
end
end
setup_prefs()
--evaluates and runs the given string in both Lua 5.1 and 5.2
--the name argument is used for error reporting
--provides the mpv modules and the fb module to the string
local function evaluate_string(str, env)
msg.trace('evaluating string '..str)
env = redirect_table(_G, env)
env.mp = redirect_table(mp)
env.msg = redirect_table(msg)
env.utils = redirect_table(utils)
local chunk, err
if setfenv then
chunk, err = loadstring(str)
if chunk then setfenv(chunk, env) end
else
chunk, err = load(str, nil, 't', env)
end
if not chunk then
msg.warn('failed to load string:', str)
msg.error(err)
chunk = function() return nil end
end
local success, boolean = pcall(chunk)
if not success then msg.error(boolean) end
return boolean
end
--anticipates the default audio track
--returns the node for the predicted track
--this whole function can be skipped if the user decides to load the subtitles asynchronously instead,
--or if `--aid` is not set to `auto`
local function predict_audio()
--if the option is not set to auto then it is easy
local aid = mp.get_property("options/aid", "auto")
if aid == "no" then return NO_TRACK
elseif aid ~= "auto" then return audio_tracks[tonumber(aid)] end
local num_tracks = #audio_tracks
if num_tracks == 1 then return audio_tracks[1]
elseif num_tracks == 0 then return NO_TRACK end
local highest_priority = NO_TRACK
local priority_str = ""
local alangs = mp.get_property_native("alang", {})
--loop through the track list for any audio tracks
for _,track in ipairs(audio_tracks) do
--loop through the alang list to check if it has a preference
local pref = 0
for j,alang in ipairs(alangs) do
if track.lang == alang then
--a lower number j has higher priority, so flip the numbers around so the lowest j has highest preference
pref = 1000 - j
break
end
end
--format the important preferences so that we can easily use a lexicographical comparison to find the default
local formatted_str = string.format("%d-%03d-%d-%02d",
track.forced and 1 or 0,
pref,
track.default and 1 or 0,
num_tracks - track.id
)
msg.trace("formatted track info: " .. formatted_str)
if formatted_str > priority_str then
priority_str = formatted_str
highest_priority = track
end
end
msg.verbose("predicted audio track is "..tostring(highest_priority.id))
return highest_priority
end
--sets the subtitle track to the given sid
--this is a function to prepare for some upcoming functionality, but I've forgotten what that is
local function set_track(type, id)
msg.verbose("setting", type, "to", id)
if mp.get_property_number(type) == id then return end
mp.set_property('file-local-options/'..type, id)
end
--checks if the given audio matches the given track preference
local function is_valid_audio(audio, pref)
local alangs = type(pref.alang) == "string" and {pref.alang} or pref.alang
for _,lang in ipairs(alangs) do
msg.trace("Checking for valid audio:", lang)
if audio == NO_TRACK then
if lang == "no" then return true end
else
if lang == '*' then
return true
elseif lang == "forced" then
if audio.forced then return true end
elseif lang == "default" then
if audio.default then return true end
else
if audio.lang and audio.lang:lower():find(lang) then return true end
end
end
end
return false
end
--checks if the given sub matches the given track preference
local function is_valid_sub(sub, slang, pref)
msg.trace("checking sub", slang, "against track", utils.to_string(sub))
-- Do not try to un-nest these if statements, it will break detection of default and forced tracks.
-- I've already had to un-nest these statements twice due to this mistake, don't let it happen again.
if sub == NO_TRACK then
return slang == 'no'
else
if slang == "default" then
if not sub.default then return false end
elseif slang == "forced" then
if not sub.forced then return false end
else
if sub.forced and o.explicit_forced_subs then return false end
if not sub.lang:lower():find(slang) and slang ~= "*" then return false end
end
end
local title = sub.title or ''
-- if the whitelist is not set then we don't need to find anything
local passes_whitelist = not pref.whitelist
local passes_blacklist = true
-- whitelist/blacklist handling
if pref.whitelist and title then
for _,word in ipairs(pref.whitelist) do
if title:lower():find(word) then passes_whitelist = true end
end
end
if pref.blacklist and title then
for _,word in ipairs(pref.blacklist) do
if title:lower():find(word) then passes_blacklist = false end
end
end
msg.trace(string.format("%s %s whitelist: %s | %s blacklist: %s",
title,
passes_whitelist and "passed" or "failed", utils.to_string(pref.whitelist),
passes_blacklist and "passed" or "failed", utils.to_string(pref.blacklist)
))
return passes_whitelist and passes_blacklist
end
--scans the track list and selects audio and subtitle tracks which match the track preferences
--if an audio track is provided to the function it will assume this track is the only audio
local function find_valid_tracks(manual_audio)
assert(manual_audio == nil or (type(manual_audio) == 'table' and manual_audio.id), 'argument must be an audio track or nil')
local sub_track_list = {NO_TRACK, unpack(sub_tracks)}
local audio_track_list
if manual_audio == nil then
audio_track_list = {NO_TRACK, unpack(audio_tracks)}
else
audio_track_list = {manual_audio}
end
if manual_audio then msg.debug("select subtitle for", utils.to_string(manual_audio))
else msg.debug('selecting audio and subtitles') end
--searching the selection presets for one that applies to this track
for _,pref in ipairs(prefs) do
msg.debug("checking pref:", utils.to_string(pref))
for _, audio_track in ipairs(audio_track_list) do
if is_valid_audio(audio_track, pref) then
local aid = audio_track and audio_track.id
--checks if any of the subtitle tracks match the preset for the current audio
local slangs = type(pref.slang) == "string" and {pref.slang} or pref.slang
msg.trace("valid audio preference found:", utils.to_string(pref.alang))
for _, slang in ipairs(slangs) do
msg.trace("checking for valid sub:", slang)
for _,sub_track in ipairs(sub_track_list) do
if is_valid_sub(sub_track, slang, pref)
and (not pref.condition or (evaluate_string('return '..pref.condition, {
audio = aid > 0 and audio_track or nil,
sub = sub_track.id > 0 and sub_track or nil
}) == true))
then
msg.verbose("valid audio preference found:", utils.to_string(pref.alang))
msg.verbose("valid subtitle preference found:", utils.to_string(pref.slang))
return aid, sub_track and sub_track.id
end
end
end
end
end
end
end
--returns the audio node for the currently playing audio track
local function find_current_audio()
local aid = mp.get_property_number("aid", 0)
return audio_tracks[aid] or NO_TRACK
end
--extract the language code from an audio track node and pass it to select_subtitles
local function select_tracks(audio)
local aid, sid = find_valid_tracks(audio)
if sid then
set_track('sid', sid == 0 and 'no' or sid)
end
if aid and o.select_audio then
set_track('aid', aid == 0 and 'no' or aid)
end
latest_audio = audio or find_current_audio()
end
--select subtitles asynchronously after playback start
local function async_load()
select_tracks(not o.select_audio and find_current_audio() or nil)
end
--select subtitles synchronously during the on_preloaded hook
local function preload()
if o.select_audio then return select_tracks() end
local audio = predict_audio()
select_tracks(audio)
latest_audio = audio
end
local track_auto_selection = true
mp.observe_property("track-auto-selection", "bool", function(_,b) track_auto_selection = b end)
local function selection_enabled()
if not ENABLED then return false end
if not track_auto_selection then return false end
if #sub_tracks == 0 then return false end
return true
end
local INITIAL_LOAD = true
local ORIGINAL_SID = mp.get_property('options/sid')
mp.add_hook('on_load', 50, function()
INITIAL_LOAD = true
ORIGINAL_SID = mp.get_property('options/sid')
end)
--reselect the subtitles if the audio is different from what was last used
local function reselect_subtitles()
local initial = INITIAL_LOAD
INITIAL_LOAD = false
if not selection_enabled() then return end
local audio = find_current_audio()
if latest_audio.id ~= audio.id and (not initial or ORIGINAL_SID == 'auto') then
msg.info("detected audio change - reselecting subtitles")
select_tracks(audio)
end
end
--setups the audio and subtitle track lists to use for the rest of the script
local function read_track_list()
local track_list = mp.get_property_native("track-list", {})
audio_tracks = {}
sub_tracks = {}
for _,track in ipairs(track_list) do
if not track.lang then track.lang = "und" end
if track.type == "audio" then
table.insert(audio_tracks, track)
elseif track.type == "sub" then
table.insert(sub_tracks, track)
end
end
end
local function observe_audio_switches()
mp.observe_property("aid", "string", reselect_subtitles)
end
local function unobserve_audio_switches()
mp.unobserve_property(reselect_subtitles)
end
mp.add_hook('on_preloaded', 25, read_track_list)
mp.add_hook('on_preloaded', 26, function() latest_audio = predict_audio() end)
--events for file loading
mp.add_hook('on_preloaded', 30, function()
if not selection_enabled() then return end
if mp.get_property('options/sid') ~= 'auto' then return end
preload()
end)
if o.observe_audio_switches then
mp.register_event('file-loaded', observe_audio_switches)
mp.add_hook('on_unload', 50, unobserve_audio_switches)
else
mp.register_event('file-loaded', reselect_subtitles)
end
mp.observe_property('track-list/count', 'number', read_track_list)
--force subtitle selection during playback
mp.register_script_message("select-subtitles", async_load)
--toggle sub-select during playback
mp.register_script_message("sub-select", function(arg)
if arg == "toggle" then ENABLED = not ENABLED
elseif arg == "enable" then ENABLED = true
elseif arg == "disable" then ENABLED = false end
local str = "sub-select: ".. (ENABLED and "enabled" or "disabled")
mp.osd_message(str)
if not selection_enabled() then return end
async_load()
end)