1use std::{
7 borrow::Cow,
8 fs::{read_dir, remove_file},
9 io,
10 path::Path,
11 process::Stdio,
12 time::Duration,
13};
14
15use futures::future::try_join;
16#[cfg(unix)]
17use libc::{SIGKILL, killpg};
18use lru_cache::LruCache;
19use ruma::Mxc;
20use tokio::{
21 fs::{OpenOptions, create_dir_all},
22 io::{AsyncRead, AsyncReadExt, AsyncWriteExt, copy, sink},
23 process::Command,
24 time::{Instant, timeout_at},
25};
26use tuwunel_core::{
27 Config, Err, Result, debug, debug_warn, defer, err, implement,
28 utils::{BoolExt, random_string},
29};
30
31use super::{Dim, Media};
32
33const INPUT: &str = "{input}";
35const WIDTH: &str = "{width}";
36const HEIGHT: &str = "{height}";
37
38const VIDEO: &str = "video/";
40
41const STAGED: &str = "tuwunel-video-";
44
45const NAME_LENGTH: usize = 16;
46
47const DIAGNOSTIC_LEN: u64 = 4096;
48
49const FAILURE_COOLDOWN: Duration = Duration::from_mins(5);
53
54pub(super) const FAILURES: usize = 1024;
56
57const FAIR_SHARE: f64 = 4.0;
60
61pub(super) type Failures = LruCache<String, Instant>;
64
65struct Reaper {
70 group: Option<u32>,
71}
72
73impl Reaper {
74 fn disarm(&mut self) { self.group = None; }
75}
76
77impl Drop for Reaper {
78 fn drop(&mut self) {
79 if let Some(group) = self.group {
80 kill_group(group);
81 }
82 }
83}
84
85#[implement(super::Service)]
88#[tracing::instrument(
89 name = "video",
90 level = "debug",
91 skip_all,
92 fields(
93 ?dim,
94 content_type = ?media.content_type,
95 ),
96)]
97pub(super) async fn video_frame(
98 &self,
99 mxc: &Mxc<'_>,
100 dim: &Dim,
101 media: &Media,
102) -> Option<Vec<u8>> {
103 let config = &self.services.config;
104
105 let is_video = media
106 .content_type
107 .as_deref()
108 .is_some_and(|content_type| content_type.starts_with(VIDEO));
109
110 let workable = !config.media_video_thumbnail_command.is_empty()
111 && media.content.len() <= config.media_video_thumbnail_max_size
112 && !self.failed_recently(mxc);
113
114 is_video
115 .and_is(workable)
116 .then_async(|| self.extract_frame(mxc, dim, &media.content))
117 .await?
118 .inspect_err(|e| debug_warn!(%e, "Failed to extract a video frame."))
119 .ok()
120}
121
122#[implement(super::Service)]
125fn failed_recently(&self, mxc: &Mxc<'_>) -> bool {
126 let Some(cooldown) = Instant::now().checked_sub(FAILURE_COOLDOWN) else {
127 return false;
128 };
129
130 self.video_thumbnail_failures
131 .lock()
132 .ok()
133 .and_then(|mut failures| failures.get_mut(&mxc.to_string()).copied())
134 .is_some_and(|failed| failed > cooldown)
135}
136
137#[implement(super::Service)]
138pub(super) fn remember_failure(&self, mxc: &Mxc<'_>) {
139 if let Ok(mut failures) = self.video_thumbnail_failures.lock() {
140 failures.insert(mxc.to_string(), Instant::now());
141 }
142}
143
144#[implement(super::Service)]
145#[tracing::instrument(level = "debug", skip(self, content))]
146async fn extract_frame(&self, mxc: &Mxc<'_>, dim: &Dim, content: &[u8]) -> Result<Vec<u8>> {
147 let config = &self.services.config;
148 let timeout = Duration::from_secs(config.media_video_thumbnail_timeout);
149
150 let deadline = Instant::now()
153 .checked_add(timeout)
154 .ok_or_else(|| {
155 err!(Config("media_video_thumbnail_timeout", "Timeout is out of range."))
156 })?;
157
158 let Some((program, args)) = config.media_video_thumbnail_command.split_first() else {
159 return Err!(Config("media_video_thumbnail_command", "No program is configured."));
160 };
161
162 let Ok(Ok(_slot)) = timeout_at(deadline, self.video_thumbnail_slots.acquire()).await else {
163 return Err!("Timed out waiting for a video thumbnail slot.");
164 };
165
166 let path = staging_dir(config).join(format!("{STAGED}{}", random_string(NAME_LENGTH)));
167
168 defer! {{ remove_file(&path).ok(); }}
169
170 let Ok(staged) = timeout_at(deadline, stage(&path, content)).await else {
171 return Err!("Timed out staging the video.");
172 };
173
174 staged?;
175
176 if deadline.saturating_duration_since(Instant::now()) < timeout.div_f64(FAIR_SHARE) {
179 return Err!("Too little of the deadline remained to run the video thumbnail program.");
180 }
181
182 let width = dim.width.to_string();
183 let height = dim.height.to_string();
184 let args = args
185 .iter()
186 .map(|arg| substitute(arg, &path, &width, &height));
187
188 let limit = u64::try_from(config.media_video_thumbnail_max_size).unwrap_or(u64::MAX);
189 let frame = run(program, args, limit, deadline).await;
190
191 if frame.is_err() {
194 self.remember_failure(mxc);
195 }
196
197 frame
198}
199
200fn staging_dir(config: &Config) -> Cow<'_, Path> {
203 config
204 .media_video_thumbnail_path
205 .as_deref()
206 .map_or_else(|| config.database_path.join("tmp").into(), Cow::Borrowed)
207}
208
209#[tracing::instrument(name = "sweep", level = "debug", skip_all)]
214pub(super) fn sweep_staging_dir(config: &Config) {
215 let Ok(dir) = read_dir(staging_dir(config).as_ref()) else {
216 return;
217 };
218
219 dir.filter_map(Result::ok)
220 .map(|entry| entry.path())
221 .filter(|path| {
222 path.file_name()
223 .and_then(|name| name.to_str())
224 .is_some_and(|name| name.starts_with(STAGED))
225 })
226 .for_each(|path| {
227 debug!(?path, "Removing a video staged by a previous run.");
228 remove_file(&path).ok();
229 });
230}
231
232async fn stage(path: &Path, content: &[u8]) -> Result {
235 if let Some(dir) = path.parent() {
236 create_dir_all(dir).await?;
237 }
238
239 let mut options = OpenOptions::new();
240 options.create_new(true).write(true);
241
242 #[cfg(unix)]
243 options.mode(0o600);
244
245 let mut file = options.open(path).await?;
246
247 file.write_all(content).await?;
248 file.flush().await?;
249
250 Ok(())
251}
252
253pub(super) fn substitute<'a>(
254 arg: &'a str,
255 input: &Path,
256 width: &str,
257 height: &str,
258) -> Cow<'a, str> {
259 let substituted = || {
260 arg.replace(INPUT, &input.to_string_lossy())
261 .replace(WIDTH, width)
262 .replace(HEIGHT, height)
263 .into()
264 };
265
266 arg.contains('{')
267 .then(substituted)
268 .unwrap_or(Cow::Borrowed(arg))
269}
270
271#[tracing::instrument(
276 level = "debug",
277 skip(args),
278 fields(
279 %program,
280 %limit,
281 ),
282)]
283pub(super) async fn run<Args>(
284 program: &str,
285 args: Args,
286 limit: u64,
287 deadline: Instant,
288) -> Result<Vec<u8>>
289where
290 Args: IntoIterator<Item: AsRef<str>> + Send,
291{
292 let mut command = Command::new(program);
293 command
294 .stdin(Stdio::null())
295 .stdout(Stdio::piped())
296 .stderr(Stdio::piped())
297 .kill_on_drop(true);
298
299 for arg in args {
300 command.arg(arg.as_ref());
301 }
302
303 #[cfg(unix)]
304 command.process_group(0);
305
306 let mut child = command.spawn()?;
307 let mut reaper = Reaper { group: child.id() };
308
309 let stdout = child.stdout.take().expect("stdout is piped");
310 let stderr = child.stderr.take().expect("stderr is piped");
311
312 let collect = try_join(drain(stdout, limit.saturating_add(1)), drain(stderr, DIAGNOSTIC_LEN));
314
315 let outcome = timeout_at(deadline, async {
318 let collected = collect.await?;
319 let status = child.wait().await?;
320
321 Ok::<_, io::Error>((collected, status))
322 })
323 .await;
324
325 if child.id().is_none() {
329 reaper.disarm();
330 }
331
332 let Ok(exited) = outcome else {
333 return Err!("Video thumbnail program exceeded its deadline.");
334 };
335
336 let ((frame, diagnostic), status) = exited?;
337 let diagnostic = String::from_utf8_lossy(&diagnostic);
338
339 debug!(?status, len = %frame.len(), "Video thumbnail program exited.");
340
341 if !status.success() {
342 return Err!("Video thumbnail program failed with {status}: {diagnostic}");
343 }
344
345 if frame.is_empty() {
346 return Err!("Video thumbnail program produced no frame: {diagnostic}");
347 }
348
349 if u64::try_from(frame.len()).unwrap_or(u64::MAX) > limit {
350 return Err!("Video thumbnail program produced a frame past {limit} bytes.");
351 }
352
353 Ok(frame)
354}
355
356async fn drain<Pipe>(mut pipe: Pipe, limit: u64) -> Result<Vec<u8>, io::Error>
357where
358 Pipe: AsyncRead + Unpin + Send,
359{
360 let mut buf = Vec::new();
361
362 (&mut pipe)
363 .take(limit)
364 .read_to_end(&mut buf)
365 .await?;
366
367 copy(&mut pipe, &mut sink()).await?;
370
371 Ok(buf)
372}
373
374#[cfg(unix)]
375fn kill_group(group: u32) {
376 let Ok(group) = i32::try_from(group) else {
377 return;
378 };
379
380 unsafe {
384 killpg(group, SIGKILL);
385 }
386}
387
388#[cfg(not(unix))]
389fn kill_group(_group: u32) {}