--[[ 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)