dotfiles

personal dotfiles
git clone anongit@rnpnr.xyz:dotfiles.git
Log | Files | Refs | Feed | Submodules

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