playlist.lua (13333B)
1 ------------------------------------------------------------------------------------------ 2 ---------------------------------File/Playlist Opening------------------------------------ 3 ------------------------------------------------------------------------------------------ 4 ------------------------------------------------------------------------------------------ 5 6 local mp = require 'mp' 7 local msg = require 'mp.msg' 8 local utils = require 'mp.utils' 9 10 local o = require 'modules.options' 11 local g = require 'modules.globals' 12 local fb_utils = require 'modules.utils' 13 local ass = require 'modules.ass' 14 local cursor = require 'modules.navigation.cursor' 15 local controls = require 'modules.controls' 16 local scanning = require 'modules.navigation.scanning' 17 local movement = require 'modules.navigation.directory-movement' 18 19 local state = g.state 20 21 ---@alias LoadfileFlag 'replace'|'append-play' 22 23 ---@class LoadOpts 24 ---@field directory string 25 ---@field flag LoadfileFlag 26 ---@field autoload boolean 27 ---@field items_appended number 28 ---@field co thread 29 ---@field concurrency number 30 31 ---In mpv v0.38 a new index argument was added to the loadfile command. 32 ---For some crazy reason this new argument is placed before the existing options 33 ---argument, breaking any scripts that used it. This function finds the correct index 34 ---for the options argument using the `command-list` property. 35 ---@return integer 36 local function get_loadfile_options_arg_index() 37 ---@type table[] 38 local command_list = mp.get_property_native('command-list', {}) 39 for _, command in ipairs(command_list) do 40 if command.name == 'loadfile' then 41 for i, arg in ipairs(command.args or {} --[=[@as table[]]=]) do 42 if arg.name == 'options' then 43 return i 44 end 45 end 46 end 47 end 48 49 return 3 50 end 51 52 local LEGACY_LOADFILE_SYNTAX = get_loadfile_options_arg_index() == 3 53 54 ---A wrapper around loadfile to handle the syntax changes introduced in mpv v0.38. 55 ---@param file string 56 ---@param flag string 57 ---@param options? string|table<string,unknown> 58 ---@return boolean 59 local function legacy_loadfile_wrapper(file, flag, options) 60 if LEGACY_LOADFILE_SYNTAX then 61 return mp.command_native({"loadfile", file, flag, options}) ~= nil 62 else 63 return mp.command_native({"loadfile", file, flag, -1, options}) ~= nil 64 end 65 end 66 67 ---Adds a file to the playlist and changes the flag to `append-play` in preparation for future items. 68 ---@param file string 69 ---@param opts LoadOpts 70 ---@param mpv_opts? string|table<string,unknown> 71 local function loadfile(file, opts, mpv_opts) 72 if o.substitute_backslash and not fb_utils.get_protocol(file) then 73 file = string.gsub(file, "/", "\\") 74 end 75 76 if opts.flag == "replace" then msg.verbose("Playling file", file) 77 else msg.verbose("Appending", file, "to the playlist") end 78 79 if mpv_opts then 80 msg.debug('Settings opts on', file, ':', utils.to_string(mpv_opts)) 81 end 82 83 if not legacy_loadfile_wrapper(file, opts.flag, mpv_opts) then msg.warn(file) end 84 opts.flag = "append-play" 85 opts.items_appended = opts.items_appended + 1 86 end 87 88 ---@diagnostic disable-next-line no-unknown 89 local concurrent_loadlist_wrapper 90 91 ---@alias ConcurrentRefMap table<List|Item,{directory: string?, sublist: List?, recurse: boolean?}> 92 93 ---This function recursively loads directories concurrently in separate coroutines. 94 ---Results are saved in a tree of tables that allows asynchronous access. 95 ---@async 96 ---@param directory string 97 ---@param load_opts LoadOpts 98 ---@param prev_dirs Set<string> 99 ---@param item_t Item 100 ---@param refs ConcurrentRefMap 101 ---@return boolean? 102 local function concurrent_loadlist_parse(directory, load_opts, prev_dirs, item_t, refs) 103 if not refs[item_t] then refs[item_t] = {} end 104 105 --prevents infinite recursion from the item.path or opts.directory fields 106 if prev_dirs[directory] then return end 107 prev_dirs[directory] = true 108 109 local list, list_opts = scanning.scan_directory(directory, { source = 'loadlist' }) 110 if list == g.root then return end 111 112 --if we can't parse the directory then append it and hope mpv fares better 113 if list == nil then 114 msg.warn("Could not parse", directory, "appending to playlist anyway") 115 refs[item_t].recurse = false 116 return 117 end 118 119 directory = list_opts.directory or directory 120 121 --we must declare these before we start loading sublists otherwise the append thread will 122 --need to wait until the whole list is loaded (when synchronous IO is used) 123 refs[item_t].sublist = list or {} 124 refs[list] = {directory = directory} 125 126 if directory == "" then return end 127 128 --launches new parse operations for directories, each in a different coroutine 129 for _, item in ipairs(list) do 130 if fb_utils.parseable_item(item) then 131 fb_utils.coroutine.run(concurrent_loadlist_wrapper, fb_utils.get_new_directory(item, directory), load_opts, prev_dirs, item, refs) 132 end 133 end 134 return true 135 end 136 137 ---A wrapper function that ensures the concurrent_loadlist_parse is run correctly. 138 ---@async 139 ---@param directory string 140 ---@param opts LoadOpts 141 ---@param prev_dirs Set<string> 142 ---@param item Item 143 ---@param refs ConcurrentRefMap 144 function concurrent_loadlist_wrapper(directory, opts, prev_dirs, item, refs) 145 --ensures that only a set number of concurrent parses are operating at any one time. 146 --the mpv event queue is seemingly limited to 1000 items, but only async mpv actions like 147 --command_native_async should use that, events like mp.add_timeout (which coroutine.sleep() uses) should 148 --be handled enturely on the Lua side with a table, which has a significantly larger maximum size. 149 while (opts.concurrency > o.max_concurrency) do 150 fb_utils.coroutine.sleep(0.1) 151 end 152 opts.concurrency = opts.concurrency + 1 153 154 local success = concurrent_loadlist_parse(directory, opts, prev_dirs, item, refs) 155 opts.concurrency = opts.concurrency - 1 156 if not success then refs[item].sublist = {} end 157 if coroutine.status(opts.co) == "suspended" then fb_utils.coroutine.resume_err(opts.co) end 158 end 159 160 ---Recursively appends items to the playlist, acts as a consumer to the previous functions producer; 161 ---If the next directory has not been parsed this function will yield until the parse has completed. 162 ---@async 163 ---@param list List 164 ---@param load_opts LoadOpts 165 ---@param refs ConcurrentRefMap 166 local function concurrent_loadlist_append(list, load_opts, refs) 167 local directory = refs[list].directory 168 169 for _, item in ipairs(list) do 170 if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ] 171 and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ] 172 then 173 while fb_utils.parseable_item(item) and (not refs[item] or not refs[item].sublist) do 174 coroutine.yield() 175 end 176 177 if fb_utils.parseable_item(item) and refs[item] ~= false then 178 concurrent_loadlist_append(refs[item].sublist, load_opts, refs) 179 else 180 loadfile(fb_utils.get_full_path(item, directory), load_opts, item.mpv_options) 181 end 182 end 183 end 184 end 185 186 ---Recursive function to load directories serially. 187 ---Returns true if any items were appended to the playlist. 188 ---@async 189 ---@param directory string 190 ---@param load_opts LoadOpts 191 ---@param prev_dirs Set<string> 192 ---@return true|nil 193 local function custom_loadlist_recursive(directory, load_opts, prev_dirs) 194 --prevents infinite recursion from the item.path or opts.directory fields 195 if prev_dirs[directory] then return end 196 prev_dirs[directory] = true 197 198 local list, opts = scanning.scan_directory(directory, { source = "loadlist" }) 199 if list == g.root then return end 200 201 --if we can't parse the directory then append it and hope mpv fares better 202 if list == nil then 203 msg.warn("Could not parse", directory, "appending to playlist anyway") 204 loadfile(directory, load_opts) 205 return true 206 end 207 208 directory = opts.directory or directory 209 if directory == "" then return end 210 211 for _, item in ipairs(list) do 212 if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ] 213 and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ] 214 then 215 if fb_utils.parseable_item(item) then 216 custom_loadlist_recursive( fb_utils.get_new_directory(item, directory) , load_opts, prev_dirs) 217 else 218 local path = fb_utils.get_full_path(item, directory) 219 loadfile(path, load_opts, item.mpv_options) 220 end 221 end 222 end 223 end 224 225 226 ---A wrapper for the custom_loadlist_recursive function. 227 ---@async 228 ---@param item Item 229 ---@param opts LoadOpts 230 local function loadlist(item, opts) 231 local dir = fb_utils.get_full_path(item, opts.directory) 232 local num_items = opts.items_appended 233 234 if o.concurrent_recursion then 235 item = fb_utils.copy_table(item) 236 opts.co = fb_utils.coroutine.assert() 237 opts.concurrency = 0 238 239 ---@type List 240 local v_list = {item} 241 ---@type ConcurrentRefMap 242 local refs = setmetatable({[v_list] = {directory = opts.directory}}, {__mode = 'k'}) 243 244 --we need the current coroutine to suspend before we run the first parse operation, so 245 --we schedule the coroutine to run on the mpv event queue 246 fb_utils.coroutine.queue(concurrent_loadlist_wrapper, dir, opts, {}, item, refs) 247 concurrent_loadlist_append(v_list, opts, refs) 248 else 249 custom_loadlist_recursive(dir, opts, {}) 250 end 251 252 if opts.items_appended == num_items then msg.warn(dir, "contained no valid files") end 253 end 254 255 ---Load playlist entries before and after the currently playing file. 256 ---@param path string 257 ---@param opts LoadOpts 258 local function autoload_dir(path, opts) 259 if o.autoload_save_current and path == g.current_file.path then 260 mp.commandv("write-watch-later-config") end 261 262 --loads the currently selected file, clearing the playlist in the process 263 loadfile(path, opts) 264 265 local pos = 1 266 local file_count = 0 267 for _,item in ipairs(state.list) do 268 if item.type == "file" 269 and not g.sub_extensions[ fb_utils.get_extension(item.name, "") ] 270 and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ] 271 then 272 local p = fb_utils.get_full_path(item) 273 274 if p == path then pos = file_count 275 else loadfile( p, opts, item.mpv_options) end 276 277 file_count = file_count + 1 278 end 279 end 280 mp.commandv("playlist-move", 0, pos+1) 281 end 282 283 ---Runs the loadfile or loadlist command. 284 ---@async 285 ---@param item Item 286 ---@param opts LoadOpts 287 ---@return nil 288 local function open_item(item, opts) 289 if fb_utils.parseable_item(item) then 290 return loadlist(item, opts) 291 end 292 293 local path = fb_utils.get_full_path(item, opts.directory) 294 if g.sub_extensions[ fb_utils.get_extension(item.name, "") ] then 295 mp.commandv("sub-add", path, opts.flag == "replace" and "select" or "auto") 296 elseif g.audio_extensions[ fb_utils.get_extension(item.name, "") ] then 297 mp.commandv("audio-add", path, opts.flag == "replace" and "select" or "auto") 298 else 299 if opts.autoload then autoload_dir(path, opts) 300 else loadfile(path, opts, item.mpv_options) end 301 end 302 end 303 304 ---Handles the open options as a coroutine. 305 ---Once loadfile has been run we can no-longer guarantee synchronous execution - the state values may change 306 ---therefore, we must ensure that any state values that could be used after a loadfile call are saved beforehand. 307 ---@async 308 ---@param opts LoadOpts 309 ---@return nil 310 local function open_file_coroutine(opts) 311 if not state.list[state.selected] then return end 312 if opts.flag == 'replace' then controls.close() end 313 314 --we want to set the idle option to yes to ensure that if the first item 315 --fails to load then the player has a chance to attempt to load further items (for async append operations) 316 local idle = mp.get_property("idle", "once") 317 mp.set_property("idle", "yes") 318 319 --handles multi-selection behaviour 320 if next(state.selection) then 321 local selection = fb_utils.sort_keys(state.selection) 322 --reset the selection after 323 state.selection = {} 324 325 cursor.disable_select_mode() 326 ass.update_ass() 327 328 --the currently selected file will be loaded according to the flag 329 --the flag variable will be switched to append once a file is loaded 330 for i=1, #selection do 331 open_item(selection[i], opts) 332 end 333 334 else 335 local item = state.list[state.selected] 336 if opts.flag == "replace" then movement.down_dir() end 337 open_item(item, opts) 338 end 339 340 if mp.get_property("idle") == "yes" then mp.set_property("idle", idle) end 341 end 342 343 --opens the selelected file(s) 344 local function open_file(flag, autoload) 345 ---@type LoadOpts 346 local opts = { 347 flag = flag, 348 autoload = (autoload ~= o.autoload and flag == "replace"), 349 directory = state.directory, 350 items_appended = 0, 351 concurrency = 0, 352 co = coroutine.create(open_file_coroutine) 353 } 354 fb_utils.coroutine.resume_err(opts.co, opts) 355 end 356 357 ---@class playlist 358 return { 359 add_files = open_file, 360 }