summaryrefslogtreecommitdiff
path: root/mac/.config/mpv/scripts/gallery-thumbgen.lua
blob: dc0db1a0c4a3425bab977912ab0e1f4d899eccc6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
--[[
mpv-gallery-view | https://github.com/occivink/mpv-gallery-view

This mpv script implements a worker for generating gallery thumbnails.
It is meant to be used by other scripts.
Multiple copies of this script can be loaded by mpv.

File placement: inside scripts directory
Settings: script-opts/gallery_worker.conf
]]

local utils = require("mp.utils")
local msg = require("mp.msg")

local jobs_queue = {} -- queue of thumbnail jobs
local failed = {} -- list of failed output paths, to avoid redoing them
local script_id = mp.get_script_name() .. utils.getpid()

local opts = {
	ytdl_exclude = "",
};
(require("mp.options")).read_options(opts, "gallery_worker")

local ytdl = {
	path = "youtube-dl",
	searched = false,
	blacklisted = {}, -- Add patterns of URLs you want blacklisted from youtube-dl,
	-- see gallery_worker.conf or ytdl_hook-exclude in the mpv manpage for more info
}

function append_table(lhs, rhs)
	for i = 1, #rhs do
		lhs[#lhs + 1] = rhs[i]
	end
	return lhs
end

local function file_exists(path)
	local info = utils.file_info(path)
	return info ~= nil and info.is_file
end

local video_extensions = { "mkv", "webm", "mp4", "avi", "wmv" }

function is_video(input_path)
	local extension = string.match(input_path, "%.([^.]+)$")
	if extension then
		extension = string.lower(extension)
		for _, ext in ipairs(video_extensions) do
			if extension == ext then
				return true
			end
		end
	end
	return false
end

function is_blacklisted(url)
	if opts.ytdl_exclude == "" then
		return false
	end
	if #ytdl.blacklisted == 0 then
		local joined = opts.ytdl_exclude
		while joined:match("%|?[^|]+") do
			local _, e, substring = joined:find("%|?([^|]+)")
			table.insert(ytdl.blacklisted, substring)
			joined = joined:sub(e + 1)
		end
	end
	if #ytdl.blacklisted > 0 then
		url = url:match("https?://(.+)")
		for _, exclude in ipairs(ytdl.blacklisted) do
			if url:match(exclude) then
				msg.verbose("URL matches excluded substring. Skipping.")
				return true
			end
		end
	end
	return false
end

function ytdl_thumbnail_url(input_path)
	local function exec(args)
		local ret = utils.subprocess({ args = args, cancellable = false })
		return ret.status, ret.stdout, ret
	end
	local function first_non_nil(x, ...)
		if x ~= nil then
			return x
		end
		return first_non_nil(...)
	end

	-- if input_path is youtube, generate our own URL
	youtube_id1 = string.match(input_path, "https?://youtu%.be/([%a%d%-_]+).*")
	youtube_id2 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/v/([%a%d%-_]+).*")
	youtube_id3 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/watch%?v=([%a%d%-_]+).*")
	youtube_id4 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/embed/([%a%d%-_]+).*")
	youtube_id = youtube_id1 or youtube_id2 or youtube_id3 or youtube_id4

	if youtube_id then
		-- the hqdefault.jpg thumbnail should always exist, since it's used on the search result page
		return "https://i.ytimg.com/vi/" .. youtube_id .. "/hqdefault.jpg"
	end

	--otherwise proceed with the slower `youtube-dl -J` method
	if not ytdl.searched then --search for youtude-dl in mpv's config directory
		local exesuf = (package.config:sub(1, 1) == "\\") and ".exe" or ""
		local ytdl_mcd = mp.find_config_file("youtube-dl")
		if not (ytdl_mcd == nil) then
			msg.error("found youtube-dl at: " .. ytdl_mcd)
			ytdl.path = ytdl_mcd
		end
		ytdl.searched = true
	end
	local command = { ytdl.path, "--no-warnings", "--no-playlist", "-J", input_path }
	local es, json, result = exec(command)

	if (es < 0) or (json == nil) or (json == "") then
		msg.error("fetching thumbnail url with youtube-dl failed for" .. input_path)
		return input_path
	end
	local json, err = utils.parse_json(json)
	if json == nil then
		msg.error("failed to parse json for youtube-dl thumbnail: " .. err)
		return input_path
	end

	if (json.thumbnail == nil) or (json.thumbnail == "") then
		msg.error("no thumbnail url from youtube-dl.")
		return input_path
	end
	return json.thumbnail
end

function thumbnail_command(input_path, width, height, take_thumbnail_at, output_path, accurate, with_mpv)
	local vf = string.format(
		"%s,%s",
		string.format("scale=iw*min(1\\,min(%d/iw\\,%d/ih)):-2", width, height),
		string.format("pad=%d:%d:(%d-iw)/2:(%d-ih)/2:color=0x00000000", width, height, width, height)
	)
	local out = {}
	local add = function(table)
		out = append_table(out, table)
	end

	if input_path:find("^https?://") and not is_blacklisted(input_path) then
		-- returns the original input_path on failure
		input_path = ytdl_thumbnail_url(input_path)
	end

	if input_path:find("^archive://") or input_path:find("^edl://") then
		with_mpv = true
	end

	if not with_mpv then
		out = { "ffmpeg" }
		if is_video(input_path) then
			if string.sub(take_thumbnail_at, -1) == "%" then
				--if only fucking ffmpeg supported percent-style seeking
				local res = utils.subprocess({
					args = {
						"ffprobe",
						"-v",
						"error",
						"-show_entries",
						"format=duration",
						"-of",
						"default=noprint_wrappers=1:nokey=1",
						input_path,
					},
					cancellable = false,
				})
				if res.status == 0 then
					local duration = tonumber(string.match(res.stdout, "^%s*(.-)%s*$"))
					if duration then
						local percent = tonumber(string.sub(take_thumbnail_at, 1, -2))
						local start = tostring(duration * percent / 100)
						add({ "-ss", start })
					end
				end
			else
				add({ "-ss", take_thumbnail_at })
			end
		end
		if not accurate then
			add({ "-noaccurate_seek" })
		end
		add({
			"-i",
			input_path,
			"-vf",
			vf,
			"-map",
			"v:0",
			"-f",
			"rawvideo",
			"-pix_fmt",
			"bgra",
			"-c:v",
			"rawvideo",
			"-frames:v",
			"1",
			"-y",
			"-loglevel",
			"quiet",
			output_path,
		})
	else
		out = { "mpv", input_path }
		if take_thumbnail_at ~= "0" and is_video(input_path) then
			if not accurate then
				add({ "--hr-seek=no" })
			end
			add({ "--start=" .. take_thumbnail_at })
		end
		add({
			"--no-config",
			"--msg-level=all=no",
			"--vf=lavfi=[" .. vf .. ",format=bgra]",
			"--audio=no",
			"--sub=no",
			"--frames=1",
			"--image-display-duration=0",
			"--of=rawvideo",
			"--ovc=rawvideo",
			"--o=" .. output_path,
		})
	end
	return out
end

function generate_thumbnail(thumbnail_job)
	if file_exists(thumbnail_job.output_path) then
		return true
	end

	local dir, _ = utils.split_path(thumbnail_job.output_path)
	local tmp_output_path = utils.join_path(dir, script_id)

	local command = thumbnail_command(
		thumbnail_job.input_path,
		thumbnail_job.width,
		thumbnail_job.height,
		thumbnail_job.take_thumbnail_at,
		tmp_output_path,
		thumbnail_job.accurate,
		thumbnail_job.with_mpv
	)

	local res = utils.subprocess({ args = command, cancellable = false })
	--"atomically" generate the output to avoid loading half-generated thumbnails (results in crashes)
	if res.status == 0 then
		local info = utils.file_info(tmp_output_path)
		if not info or not info.is_file or info.size == 0 then
			return false
		end
		if os.rename(tmp_output_path, thumbnail_job.output_path) then
			return true
		end
	end
	return false
end

function handle_events(wait)
	e = mp.wait_event(wait)
	while e.event ~= "none" do
		if e.event == "shutdown" then
			return false
		elseif e.event == "client-message" then
			if e.args[1] == "push-thumbnail-front" or e.args[1] == "push-thumbnail-back" then
				local thumbnail_job = {
					requester = e.args[2],
					input_path = e.args[3],
					width = tonumber(e.args[4]),
					height = tonumber(e.args[5]),
					take_thumbnail_at = e.args[6],
					output_path = e.args[7],
					accurate = (e.args[8] == "true"),
					with_mpv = (e.args[9] == "true"),
				}
				if e.args[1] == "push-thumbnail-front" then
					jobs_queue[#jobs_queue + 1] = thumbnail_job
				else
					table.insert(jobs_queue, 1, thumbnail_job)
				end
			end
		end
		e = mp.wait_event(0)
	end
	return true
end

local registration_timeout = 2 -- seconds
local registration_period = 0.2

-- shitty custom event loop because I can't figure out a better way
-- works pretty well though
function mp_event_loop()
	local start_time = mp.get_time()
	local sleep_time = registration_period
	local last_broadcast_time = -registration_period
	local broadcast_func
	broadcast_func = function()
		local now = mp.get_time()
		if now >= start_time + registration_timeout then
			mp.commandv("script-message", "thumbnails-generator-broadcast", mp.get_script_name())
			sleep_time = 1e20
			broadcast_func = function() end
		elseif now >= last_broadcast_time + registration_period then
			mp.commandv("script-message", "thumbnails-generator-broadcast", mp.get_script_name())
			last_broadcast_time = now
		end
	end

	while true do
		if not handle_events(sleep_time) then
			return
		end
		broadcast_func()
		while #jobs_queue > 0 do
			local thumbnail_job = jobs_queue[#jobs_queue]
			if not failed[thumbnail_job.output_path] then
				if generate_thumbnail(thumbnail_job) then
					mp.commandv(
						"script-message-to",
						thumbnail_job.requester,
						"thumbnail-generated",
						thumbnail_job.output_path
					)
				else
					failed[thumbnail_job.output_path] = true
				end
			end
			jobs_queue[#jobs_queue] = nil
			if not handle_events(0) then
				return
			end
			broadcast_func()
		end
	end
end