429 lines
15 KiB
Lua
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) |