gallery-thumbgen.lua (10408B)
1 local utils = require 'mp.utils' 2 local msg = require 'mp.msg' 3 4 local jobs_queue = {} -- queue of thumbnail jobs 5 local failed = {} -- list of failed output paths, to avoid redoing them 6 local script_id = mp.get_script_name() .. utils.getpid() 7 8 local opts = { 9 ytdl_exclude = "", 10 } 11 (require 'mp.options').read_options(opts, "gallery_worker") 12 13 local ytdl = { 14 path = "youtube-dl", 15 searched = false, 16 blacklisted = {} -- Add patterns of URLs you want blacklisted from youtube-dl, 17 -- see gallery_worker.conf or ytdl_hook-exclude in the mpv manpage for more info 18 } 19 20 function append_table(lhs, rhs) 21 for i = 1,#rhs do 22 lhs[#lhs+1] = rhs[i] 23 end 24 return lhs 25 end 26 27 local function file_exists(path) 28 local info = utils.file_info(path) 29 return info ~= nil and info.is_file 30 end 31 32 local video_extensions = { "mkv", "webm", "mp4", "avi", "wmv" } 33 34 function is_video(input_path) 35 local extension = string.match(input_path, "%.([^.]+)$") 36 if extension then 37 extension = string.lower(extension) 38 for _, ext in ipairs(video_extensions) do 39 if extension == ext then 40 return true 41 end 42 end 43 end 44 return false 45 end 46 47 function is_blacklisted(url) 48 if opts.ytdl_exclude == "" then return false end 49 if #ytdl.blacklisted == 0 then 50 local joined = opts.ytdl_exclude 51 while joined:match('%|?[^|]+') do 52 local _, e, substring = joined:find('%|?([^|]+)') 53 table.insert(ytdl.blacklisted, substring) 54 joined = joined:sub(e+1) 55 end 56 end 57 if #ytdl.blacklisted > 0 then 58 url = url:match('https?://(.+)') 59 for _, exclude in ipairs(ytdl.blacklisted) do 60 if url:match(exclude) then 61 msg.verbose('URL matches excluded substring. Skipping.') 62 return true 63 end 64 end 65 end 66 return false 67 end 68 69 70 function ytdl_thumbnail_url(input_path) 71 local function exec(args) 72 local ret = utils.subprocess({args = args, cancellable=false}) 73 return ret.status, ret.stdout, ret 74 end 75 local function first_non_nil(x, ...) 76 if x ~= nil then return x end 77 return first_non_nil(...) 78 end 79 80 -- if input_path is youtube, generate our own URL 81 youtube_id1 = string.match(input_path, "https?://youtu%.be/([%a%d%-_]+).*") 82 youtube_id2 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/v/([%a%d%-_]+).*") 83 youtube_id3 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/watch%?v=([%a%d%-_]+).*") 84 youtube_id4 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/embed/([%a%d%-_]+).*") 85 youtube_id = youtube_id1 or youtube_id2 or youtube_id3 or youtube_id4 86 87 if youtube_id then 88 -- the hqdefault.jpg thumbnail should always exist, since it's used on the search result page 89 return "https://i.ytimg.com/vi/" .. youtube_id .. "/hqdefault.jpg" 90 end 91 92 --otherwise proceed with the slower `youtube-dl -J` method 93 if not (ytdl.searched) then --search for youtude-dl in mpv's config directory 94 local exesuf = (package.config:sub(1,1) == '\\') and '.exe' or '' 95 local ytdl_mcd = mp.find_config_file("youtube-dl") 96 if not (ytdl_mcd == nil) then 97 msg.error("found youtube-dl at: " .. ytdl_mcd) 98 ytdl.path = ytdl_mcd 99 end 100 ytdl.searched = true 101 end 102 local command = {ytdl.path, "--no-warnings", "--no-playlist", "-J", input_path} 103 local es, json, result = exec(command) 104 105 if (es < 0) or (json == nil) or (json == "") then 106 msg.error("fetching thumbnail url with youtube-dl failed for" .. input_path) 107 return input_path 108 end 109 local json, err = utils.parse_json(json) 110 if (json == nil) then 111 msg.error("failed to parse json for youtube-dl thumbnail: " .. err) 112 return input_path 113 end 114 115 if (json.thumbnail == nil) or (json.thumbnail == "") then 116 msg.error("no thumbnail url from youtube-dl.") 117 return input_path 118 end 119 return json.thumbnail 120 end 121 122 function thumbnail_command(input_path, width, height, take_thumbnail_at, output_path, accurate, with_mpv) 123 local vf = string.format("%s,%s", 124 string.format("scale=iw*min(1\\,min(%d/iw\\,%d/ih)):-2", width, height), 125 string.format("pad=%d:%d:(%d-iw)/2:(%d-ih)/2:color=0x00000000", width, height, width, height) 126 ) 127 local out = {} 128 local add = function(table) out = append_table(out, table) end 129 130 131 if input_path:find("^https?://") and not is_blacklisted(input_path) then 132 -- returns the original input_path on failure 133 input_path = ytdl_thumbnail_url(input_path) 134 end 135 136 if input_path:find("^archive://") or input_path:find("^edl://") then 137 with_mpv = true 138 end 139 140 141 if not with_mpv then 142 out = { "ffmpeg" } 143 if is_video(input_path) then 144 if string.sub(take_thumbnail_at, -1) == "%" then 145 --if only fucking ffmpeg supported percent-style seeking 146 local res = utils.subprocess({ args = { 147 "ffprobe", "-v", "error", 148 "-show_entries", "format=duration", "-of", 149 "default=noprint_wrappers=1:nokey=1", input_path 150 }, cancellable = false }) 151 if res.status == 0 then 152 local duration = tonumber(string.match(res.stdout, "^%s*(.-)%s*$")) 153 if duration then 154 local percent = tonumber(string.sub(take_thumbnail_at, 1, -2)) 155 local start = tostring(duration * percent / 100) 156 add({ "-ss", start }) 157 end 158 end 159 else 160 add({ "-ss", take_thumbnail_at }) 161 end 162 end 163 if not accurate then 164 add({"-noaccurate_seek"}) 165 end 166 add({ 167 "-i", input_path, 168 "-vf", vf, 169 "-map", "v:0", 170 "-f", "rawvideo", 171 "-pix_fmt", "bgra", 172 "-c:v", "rawvideo", 173 "-frames:v", "1", 174 "-y", "-loglevel", "quiet", 175 output_path 176 }) 177 else 178 out = { "mpv", input_path } 179 if take_thumbnail_at ~= "0" and is_video(input_path) then 180 if not accurate then 181 add({ "--hr-seek=no"}) 182 end 183 add({ "--start="..take_thumbnail_at }) 184 end 185 add({ 186 "--no-config", "--msg-level=all=no", 187 "--vf=lavfi=[" .. vf .. ",format=bgra]", 188 "--audio=no", 189 "--sub=no", 190 "--frames=1", 191 "--image-display-duration=0", 192 "--of=rawvideo", "--ovc=rawvideo", 193 "--o="..output_path 194 }) 195 end 196 return out 197 end 198 199 function generate_thumbnail(thumbnail_job) 200 if file_exists(thumbnail_job.output_path) then return true end 201 202 local dir, _ = utils.split_path(thumbnail_job.output_path) 203 local tmp_output_path = utils.join_path(dir, script_id) 204 205 local command = thumbnail_command( 206 thumbnail_job.input_path, 207 thumbnail_job.width, 208 thumbnail_job.height, 209 thumbnail_job.take_thumbnail_at, 210 tmp_output_path, 211 thumbnail_job.accurate, 212 thumbnail_job.with_mpv 213 ) 214 215 local res = utils.subprocess({ args = command, cancellable = false }) 216 --"atomically" generate the output to avoid loading half-generated thumbnails (results in crashes) 217 if res.status == 0 then 218 local info = utils.file_info(tmp_output_path) 219 if not info or not info.is_file or info.size == 0 then 220 return false 221 end 222 if os.rename(tmp_output_path, thumbnail_job.output_path) then 223 return true 224 end 225 end 226 return false 227 end 228 229 function handle_events(wait) 230 e = mp.wait_event(wait) 231 while e.event ~= "none" do 232 if e.event == "shutdown" then 233 return false 234 elseif e.event == "client-message" then 235 if e.args[1] == "push-thumbnail-front" or e.args[1] == "push-thumbnail-back" then 236 local thumbnail_job = { 237 requester = e.args[2], 238 input_path = e.args[3], 239 width = tonumber(e.args[4]), 240 height = tonumber(e.args[5]), 241 take_thumbnail_at = e.args[6], 242 output_path = e.args[7], 243 accurate = (e.args[8] == "true"), 244 with_mpv = (e.args[9] == "true"), 245 } 246 if e.args[1] == "push-thumbnail-front" then 247 jobs_queue[#jobs_queue + 1] = thumbnail_job 248 else 249 table.insert(jobs_queue, 1, thumbnail_job) 250 end 251 end 252 end 253 e = mp.wait_event(0) 254 end 255 return true 256 end 257 258 local registration_timeout = 2 -- seconds 259 local registration_period = 0.2 260 261 -- shitty custom event loop because I can't figure out a better way 262 -- works pretty well though 263 function mp_event_loop() 264 local start_time = mp.get_time() 265 local sleep_time = registration_period 266 local last_broadcast_time = -registration_period 267 local broadcast_func 268 broadcast_func = function() 269 local now = mp.get_time() 270 if now >= start_time + registration_timeout then 271 mp.commandv("script-message", "thumbnails-generator-broadcast", mp.get_script_name()) 272 sleep_time = 1e20 273 broadcast_func = function() end 274 elseif now >= last_broadcast_time + registration_period then 275 mp.commandv("script-message", "thumbnails-generator-broadcast", mp.get_script_name()) 276 last_broadcast_time = now 277 end 278 end 279 280 while true do 281 if not handle_events(sleep_time) then return end 282 broadcast_func() 283 while #jobs_queue > 0 do 284 local thumbnail_job = jobs_queue[#jobs_queue] 285 if not failed[thumbnail_job.output_path] then 286 if generate_thumbnail(thumbnail_job) then 287 mp.commandv("script-message-to", thumbnail_job.requester, "thumbnail-generated", thumbnail_job.output_path) 288 else 289 failed[thumbnail_job.output_path] = true 290 end 291 end 292 jobs_queue[#jobs_queue] = nil 293 if not handle_events(0) then return end 294 broadcast_func() 295 end 296 end 297 end