better deriving
This commit is contained in:
parent
6e3d2c580b
commit
ed80b60e13
10 changed files with 371 additions and 240 deletions
|
@ -1,198 +0,0 @@
|
|||
use std::io::Cursor;
|
||||
|
||||
use infer::MatcherType;
|
||||
use serde::Deserialize;
|
||||
use ufh::derived::{DeriveFile, DeriveMedia};
|
||||
|
||||
/// derive basic information from files
|
||||
pub fn derive_file(buffer: &[u8], name: Option<&str>) -> DeriveFile {
|
||||
if let Some(mime) = infer::get(buffer) {
|
||||
let (height, width, duration) = match mime.matcher_type() {
|
||||
MatcherType::Image => {
|
||||
match image_dimensions(buffer) {
|
||||
Ok((width, height)) => (Some(width), Some(height), None),
|
||||
Err(_) => (None, None, None),
|
||||
}
|
||||
},
|
||||
// maybe use ffmpeg for these
|
||||
MatcherType::Video => (None, None, None), // TODO: extract video information
|
||||
MatcherType::Audio => (None, None, None), // TODO: extract audio information
|
||||
_ => (None, None, None),
|
||||
};
|
||||
|
||||
return DeriveFile {
|
||||
size: buffer.len() as u64,
|
||||
mime: mime.to_string(),
|
||||
height,
|
||||
width,
|
||||
duration
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = name {
|
||||
if let Some(mime) = mime_guess::from_path(name).first() {
|
||||
let (height, width, duration) = match mime.type_().into() {
|
||||
"image" => match image_dimensions(buffer) {
|
||||
Ok((width, height)) => (Some(width), Some(height), None),
|
||||
Err(_) => (None, None, None),
|
||||
},
|
||||
// maybe use ffmpeg for these
|
||||
"video" => (None, None, None), // TODO: extract video information
|
||||
"audio" => (None, None, None), // TODO: extract audio information
|
||||
_ => (None, None, None),
|
||||
};
|
||||
return DeriveFile {
|
||||
size: buffer.len() as u64,
|
||||
mime: mime.to_string(),
|
||||
height,
|
||||
width,
|
||||
duration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DeriveFile {
|
||||
size: buffer.len() as u64,
|
||||
mime: "application/octet-stream".to_owned(),
|
||||
height: None,
|
||||
width: None,
|
||||
duration: None,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: make this code less cancerous
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct FFprobeData {
|
||||
title: Option<String>,
|
||||
purl: Option<String>,
|
||||
album: Option<String>,
|
||||
artist: Option<String>,
|
||||
comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FFprobeDataWrapper (
|
||||
#[serde(deserialize_with = "serde_aux::container_attributes::deserialize_struct_case_insensitive")] FFprobeData,
|
||||
);
|
||||
|
||||
#[allow(unused)]
|
||||
/// derive tag information from media, like images/video/audio
|
||||
pub async fn derive_media(buffer: &[u8]) -> Option<DeriveMedia> {
|
||||
match infer::get(buffer)?.matcher_type() {
|
||||
MatcherType::Image | MatcherType::Audio | MatcherType::Video => (),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
use tokio::process::Command;
|
||||
use std::process::{Stdio, ChildStdin};
|
||||
use std::io::{BufWriter, Cursor};
|
||||
|
||||
let mut cmd = Command::new("/usr/bin/ffprobe")
|
||||
.args(["-v", "quiet", "-of", "json", "-show_streams", "-show_format", "-"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("couldn't find ffprobe");
|
||||
|
||||
let mut cmd_stdin = cmd.stdin
|
||||
.take()
|
||||
.expect("ffprobe should take stdin");
|
||||
|
||||
let mut cursor = Cursor::new(&buffer);
|
||||
let r = tokio::io::copy(&mut cursor, &mut cmd_stdin).await;
|
||||
drop(cmd_stdin);
|
||||
|
||||
let output = cmd.wait_with_output().await.expect("failed to read").stdout;
|
||||
let string = String::from_utf8(output).expect("ffprobe should output utf8");
|
||||
let info: serde_json::Value = serde_json::from_str(&string).expect("ffprobe should output json");
|
||||
|
||||
// TODO: find a better way to do this?
|
||||
use serde_json::Value;
|
||||
|
||||
let format_info: FFprobeData = info.get("format")
|
||||
.and_then(|a| a.get("tags"))
|
||||
.and_then(|a| serde_json::from_value(a.clone()).ok())
|
||||
.map(|a: FFprobeDataWrapper| a.0)
|
||||
.unwrap_or_default();
|
||||
let stream_info: FFprobeData = info.get("streams")
|
||||
.and_then(|a| a.get(0))
|
||||
.and_then(|a| a.get("tags"))
|
||||
.and_then(|a| serde_json::from_value(a.clone()).ok())
|
||||
.map(|a: FFprobeDataWrapper| a.0)
|
||||
.unwrap_or_default();
|
||||
|
||||
let media = DeriveMedia {
|
||||
title: stream_info.title.or(format_info.title),
|
||||
artist: stream_info.artist.or(format_info.artist),
|
||||
album: stream_info.album.or(format_info.album),
|
||||
comment: stream_info.comment.or(format_info.comment),
|
||||
url: stream_info.purl.or(format_info.purl),
|
||||
};
|
||||
|
||||
if media.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(media)
|
||||
}
|
||||
}
|
||||
|
||||
/// derive/extract text from file formats, for full text search
|
||||
// in the future, maybe i could have derive_embed with clip for image searching?
|
||||
pub async fn derive_text(buffer: &[u8], media: Option<&DeriveMedia>, mime: Option<&str>) -> Option<String> {
|
||||
fn concat(parts: &[Option<&str>]) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
for part in parts.iter().flatten() {
|
||||
out += part;
|
||||
out += " - ";
|
||||
}
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
out.truncate(out.len() - " - ".len());
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
let Some(mime) = mime else {
|
||||
// TODO: extract text from event?
|
||||
return None;
|
||||
};
|
||||
|
||||
match mime.split('/').next().unwrap() {
|
||||
"text" => String::from_utf8(buffer.into()).ok(),
|
||||
"image" | "video" | "audio" => match media {
|
||||
Some(media) => concat(&[
|
||||
media.title.as_deref(),
|
||||
media.artist.as_deref(),
|
||||
media.album.as_deref(),
|
||||
media.url.as_deref(),
|
||||
media.comment.as_deref(),
|
||||
]),
|
||||
None => None,
|
||||
},
|
||||
// MatcherType::Image => {}, // ocr
|
||||
// MatcherType::Video => {}, // fts (subtitles)
|
||||
// MatcherType::Audio => {}, // fts (lyrics)
|
||||
// MatcherType::Archive => {}, // filenames, maybe?
|
||||
// MatcherType::Book => {}, // fts (extract)
|
||||
// MatcherType::Doc => {}, // fts (extract)
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
/// derive/generate thumbnails from various files
|
||||
pub fn derive_thumbnail(buffer: &[u8]) -> Vec<u8> {
|
||||
// MatcherType::Image => {}, // thumbnail itself
|
||||
// MatcherType::Video => {}, // extract thumbnail, otherwise extract
|
||||
// MatcherType::Audio => {}, // extract album art
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn image_dimensions(buffer: &[u8]) -> Result<(u64, u64), image::ImageError> {
|
||||
let (width, height) = image::io::Reader::new(Cursor::new(buffer))
|
||||
.with_guessed_format()?
|
||||
.into_dimensions()?;
|
||||
Ok((width as u64, height as u64))
|
||||
}
|
23
server/src/derive/ffmpeg.rs
Normal file
23
server/src/derive/ffmpeg.rs
Normal file
|
@ -0,0 +1,23 @@
|
|||
use tokio::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::io::Cursor;
|
||||
|
||||
pub async fn extract(buffer: &[u8], args: &[&str]) -> Result<Vec<u8>, ()> {
|
||||
let mut cmd = Command::new("/usr/bin/ffmpeg")
|
||||
.args([&["-v", "quiet", "-i", "-"], args].concat())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("couldn't find ffmpeg");
|
||||
|
||||
let mut cmd_stdin = cmd.stdin
|
||||
.take()
|
||||
.expect("ffmpeg should take stdin");
|
||||
|
||||
let mut cursor = Cursor::new(&buffer);
|
||||
let _ = tokio::io::copy(&mut cursor, &mut cmd_stdin).await;
|
||||
drop(cmd_stdin);
|
||||
|
||||
Ok(cmd.wait_with_output().await.map_err(|_| ())?.stdout)
|
||||
}
|
122
server/src/derive/ffprobe.rs
Normal file
122
server/src/derive/ffprobe.rs
Normal file
|
@ -0,0 +1,122 @@
|
|||
use serde::{Serialize, Deserialize};
|
||||
use tokio::process::Command;
|
||||
use ufh::derived::DeriveMedia;
|
||||
use std::collections::HashMap;
|
||||
use std::process::Stdio;
|
||||
use std::io::Cursor;
|
||||
use super::ffmpeg;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Ffprobe {
|
||||
pub streams: Vec<Stream>,
|
||||
pub format: Format,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Stream {
|
||||
pub index: u64,
|
||||
pub codec_type: String,
|
||||
pub height: Option<u64>,
|
||||
pub width: Option<u64>,
|
||||
pub duration: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Tags,
|
||||
pub disposition: Disposition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Format {
|
||||
pub duration: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Tags,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Disposition {
|
||||
// if it's a thumbnail
|
||||
pub attached_pic: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Tags(#[serde(deserialize_with = "serde_aux::container_attributes::deserialize_struct_case_insensitive")] HashMap<String, String>);
|
||||
|
||||
impl Ffprobe {
|
||||
pub async fn derive(buffer: &[u8]) -> Ffprobe {
|
||||
let mut cmd = Command::new("/usr/bin/ffprobe")
|
||||
.args(["-v", "quiet", "-of", "json", "-show_streams", "-show_format", "-"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("couldn't find ffprobe");
|
||||
|
||||
let mut cmd_stdin = cmd.stdin
|
||||
.take()
|
||||
.expect("ffprobe should take stdin");
|
||||
|
||||
let mut cursor = Cursor::new(&buffer);
|
||||
let _ = tokio::io::copy(&mut cursor, &mut cmd_stdin).await;
|
||||
drop(cmd_stdin);
|
||||
|
||||
let output = cmd.wait_with_output().await.expect("failed to read").stdout;
|
||||
let string = String::from_utf8(output).expect("ffprobe should output utf8");
|
||||
serde_json::from_str(&string).expect("ffprobe should output json")
|
||||
}
|
||||
|
||||
pub fn media_info(&self) -> DeriveMedia {
|
||||
let mut info = self.format.tags.media_info();
|
||||
for stream in &self.streams {
|
||||
let new_info = stream.tags.media_info();
|
||||
info = DeriveMedia {
|
||||
title: info.title.or(new_info.title),
|
||||
album: info.album.or(new_info.album),
|
||||
artist: info.artist.or(new_info.artist),
|
||||
comment: info.comment.or(new_info.comment),
|
||||
url: info.url.or(new_info.url),
|
||||
};
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
pub fn dimensions(&self) -> (Option<u64>, Option<u64>, Option<u64>) {
|
||||
let mut dimensions = (None, None, self.format.duration.clone().and_then(|s| s.parse().ok()));
|
||||
for stream in &self.streams {
|
||||
let (width, height, duration) = stream.dimensions();
|
||||
dimensions.0 = dimensions.0.or(width);
|
||||
dimensions.1 = dimensions.1.or(height);
|
||||
dimensions.2 = dimensions.2.or(duration);
|
||||
}
|
||||
dimensions
|
||||
}
|
||||
|
||||
pub async fn thumbnail(&self, buffer: &[u8]) -> Option<Vec<u8>> {
|
||||
match self.streams.iter().find(|s| s.disposition.attached_pic == 1) {
|
||||
Some(stream) => ffmpeg::extract(buffer, &["-map", &format!("0:{}", stream.index), "-f", "webp", "-"]).await.ok(),
|
||||
None => ffmpeg::extract(buffer, &["-vframes", "1", "-f", "webp", "-"]).await.ok(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn subtitles(&self, buffer: &[u8]) -> Option<String> {
|
||||
let stream = self.streams.iter().find(|s| s.codec_type == "subtitle")?;
|
||||
String::from_utf8(ffmpeg::extract(buffer, &["-map", &format!("0:{}", stream.index), "-f", "webvtt", "-"]).await.ok()?).ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
pub fn dimensions(&self) -> (Option<u64>, Option<u64>, Option<u64>) {
|
||||
(self.width, self.height, self.duration.clone().and_then(|s| s.parse().ok()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Tags {
|
||||
pub fn media_info(&self) -> DeriveMedia {
|
||||
let map = &self.0;
|
||||
DeriveMedia {
|
||||
title: map.get("title").map(String::from),
|
||||
artist: map.get("artist").map(String::from),
|
||||
album: map.get("album").map(String::from),
|
||||
comment: map.get("comment").map(String::from),
|
||||
url: map.get("purl").map(String::from),
|
||||
}
|
||||
}
|
||||
}
|
127
server/src/derive/mod.rs
Normal file
127
server/src/derive/mod.rs
Normal file
|
@ -0,0 +1,127 @@
|
|||
use std::io::Cursor;
|
||||
use bytes::Bytes;
|
||||
use ufh::derived::{DeriveFile, DeriveMedia};
|
||||
|
||||
pub mod ffprobe;
|
||||
pub mod ffmpeg;
|
||||
|
||||
pub struct Deriver {
|
||||
ffprobe: Option<ffprobe::Ffprobe>,
|
||||
mime: String,
|
||||
major: String,
|
||||
buffer: Bytes,
|
||||
}
|
||||
|
||||
impl Deriver {
|
||||
pub async fn begin(buffer: Bytes, name: Option<&str>) -> Deriver {
|
||||
let mime = infer::get(&buffer).map(|m| m.to_string())
|
||||
.or_else(|| name.map(mime_guess::from_path).and_then(|m| m.first()).map(|m| m.to_string()))
|
||||
.unwrap_or_else(|| "application/octet-stream".into());
|
||||
let major = mime.clone().split('/').next().unwrap().to_string();
|
||||
|
||||
Deriver {
|
||||
ffprobe: match major.as_str() {
|
||||
"image" | "video" | "audio" => Some(ffprobe::Ffprobe::derive(&buffer).await),
|
||||
_ => None,
|
||||
},
|
||||
mime,
|
||||
major,
|
||||
buffer
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_file(&self) -> DeriveFile {
|
||||
let (width, height, duration) = match self.major.as_str() {
|
||||
"image" => match image_dimensions(&self.buffer) {
|
||||
Ok((width, height)) => (Some(width), Some(height), None),
|
||||
Err(_) => self.ffprobe.as_ref().unwrap().dimensions(),
|
||||
},
|
||||
"video" | "audio" => {
|
||||
self.ffprobe.as_ref().unwrap().dimensions()
|
||||
},
|
||||
_ => (None, None, None),
|
||||
};
|
||||
|
||||
DeriveFile {
|
||||
size: self.buffer.len() as u64,
|
||||
mime: self.mime.clone(),
|
||||
height,
|
||||
width,
|
||||
duration,
|
||||
}
|
||||
}
|
||||
|
||||
/// derive tag information from media, like images/video/audio
|
||||
pub async fn get_media(&self) -> Option<DeriveMedia> {
|
||||
match self.major.as_str() {
|
||||
"image" | "audio" | "video" => (),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let media = self.ffprobe.as_ref().unwrap().media_info();
|
||||
|
||||
if media.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(media)
|
||||
}
|
||||
}
|
||||
|
||||
/// derive/extract text from file formats, for full text search
|
||||
// in the future, maybe i could have derive_embed with clip for image searching?
|
||||
pub async fn get_text(&self) -> Option<String> {
|
||||
fn concat(parts: &[Option<&str>]) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
for part in parts.iter().flatten() {
|
||||
out += part;
|
||||
out += " - ";
|
||||
}
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
out.truncate(out.len() - " - ".len());
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
match self.major.as_str() {
|
||||
"text" => String::from_utf8(self.buffer.to_vec()).ok(),
|
||||
"image" | "video" | "audio" => {
|
||||
let ffprobe = self.ffprobe.as_ref().unwrap();
|
||||
let subs = ffprobe.subtitles(&self.buffer).await;
|
||||
let media = ffprobe.media_info();
|
||||
let info = concat(&[
|
||||
media.title.as_deref(),
|
||||
media.artist.as_deref(),
|
||||
media.album.as_deref(),
|
||||
media.url.as_deref(),
|
||||
media.comment.as_deref(),
|
||||
]);
|
||||
concat(&[info.as_deref(), subs.as_deref()])
|
||||
},
|
||||
// MatcherType::Image => {}, // ocr
|
||||
// MatcherType::Archive => {}, // filenames, maybe?
|
||||
// MatcherType::Book => {}, // fts (extract)
|
||||
// MatcherType::Doc => {}, // fts (extract)
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
/// derive/generate thumbnails from various files
|
||||
pub async fn get_thumbnail(&self) -> Option<Bytes> {
|
||||
match self.major.as_str() {
|
||||
"video" | "audio" => {
|
||||
self.ffprobe.as_ref().unwrap().thumbnail(&self.buffer).await.map(Bytes::from)
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn image_dimensions(buffer: &[u8]) -> Result<(u64, u64), image::ImageError> {
|
||||
let (width, height) = image::io::Reader::new(Cursor::new(buffer))
|
||||
.with_guessed_format()?
|
||||
.into_dimensions()?;
|
||||
Ok((width as u64, height as u64))
|
||||
}
|
|
@ -1,7 +1,8 @@
|
|||
use std::collections::{HashSet, HashMap};
|
||||
use bytes::Bytes;
|
||||
use tracing::debug;
|
||||
use ufh::{derived::Derived, event::{EventContent, Event, RelInfo, FileEvent}, item::ItemRef};
|
||||
use crate::{state::{db::{DbItem, sqlite::Sqlite, Database}, search::{Search, Document}}, routes::{things::Error, util::get_blob}, derive::{derive_file, derive_media, derive_text}};
|
||||
use crate::{state::{db::{DbItem, sqlite::Sqlite, Database}, search::{Search, Document}}, routes::{things::Error, util::get_blob}, derive::Deriver};
|
||||
use super::Items;
|
||||
|
||||
type Relations = HashMap<ItemRef, (Event, RelInfo)>;
|
||||
|
@ -117,16 +118,15 @@ pub async fn commit_special(me: &Items, action: &DelayedAction) -> Result<(), Er
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn derive(me: &Items, event: &Event, file: &FileEvent) -> Result<Derived, Error> {
|
||||
pub async fn derive(me: &Items, event: &Event, file: &FileEvent) -> Result<(Derived, Option<Bytes>), Error> {
|
||||
let bytes = get_blob(me, event, None).await?;
|
||||
let derived_file = derive_file(&bytes, file.name.as_deref());
|
||||
let derived_media = derive_media(&bytes).await;
|
||||
let deriver = Deriver::begin(bytes, file.name.as_deref()).await;
|
||||
let derived = Derived {
|
||||
file: Some(derived_file),
|
||||
media: derived_media,
|
||||
file: Some(deriver.get_file().await),
|
||||
media: deriver.get_media().await,
|
||||
tags: HashSet::new(),
|
||||
};
|
||||
Ok(derived)
|
||||
Ok((derived, deriver.get_thumbnail().await))
|
||||
}
|
||||
|
||||
pub async fn update_search_index(me: &Items, event: &Event, relations: &Relations) -> Result<(), Error> {
|
||||
|
@ -144,9 +144,8 @@ pub async fn update_search_index(me: &Items, event: &Event, relations: &Relation
|
|||
|
||||
pub async fn reindex(me: &Items, event: &Event) -> Result<(), Error> {
|
||||
let bytes = get_blob(me, event, None).await?;
|
||||
let mime = event.derived.file.as_ref().map(|i| i.mime.as_str());
|
||||
let file = match &event.content { EventContent::File(f) => Some(f), _ => None };
|
||||
let derived = derive_text(&bytes, event.derived.media.as_ref(), mime).await;
|
||||
let derived = Deriver::begin(bytes, None).await.get_text().await;
|
||||
let doc = Document {
|
||||
text: derived.unwrap_or_default(),
|
||||
name: file.and_then(|f| f.name.as_deref()),
|
||||
|
|
|
@ -5,7 +5,7 @@ use once_cell::sync::OnceCell;
|
|||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, info};
|
||||
use ufh::{event::{Event, WipEvent, EventContent}, item::ItemRef, derived::Derived};
|
||||
use crate::{state::{db::{sqlite::Sqlite, Database}, search::tantivy::Tantivy}, routes::things::Error, blobs, Relations, items::events::update_search_index};
|
||||
use crate::{state::{db::{sqlite::Sqlite, Database}, search::tantivy::Tantivy}, routes::things::{Error, thumbnail::ThumbnailSize}, blobs, Relations, items::events::update_search_index};
|
||||
use events::DelayedAction;
|
||||
|
||||
pub mod events;
|
||||
|
@ -107,8 +107,11 @@ impl Items {
|
|||
|
||||
let derived = if let EventContent::File(file) = &event.content {
|
||||
debug!("begin derive");
|
||||
let derived = events::derive(self, &event, file).await?;
|
||||
let (derived, thumb) = events::derive(self, &event, file).await?;
|
||||
self.db.derived_set(&event.id, &derived).await?;
|
||||
if let Some(thumb) = thumb {
|
||||
self.db.thumbnail_create(&event.id, &ThumbnailSize::Raw, &thumb).await?;
|
||||
}
|
||||
debug!("derived file info");
|
||||
derived
|
||||
} else {
|
||||
|
|
|
@ -11,7 +11,7 @@ pub mod fetch;
|
|||
mod query;
|
||||
mod blob;
|
||||
mod enumerate;
|
||||
mod thumbnail;
|
||||
pub mod thumbnail;
|
||||
|
||||
type Response<T> = Result<T, Error>;
|
||||
|
||||
|
|
|
@ -2,15 +2,15 @@ use std::fmt::Display;
|
|||
use axum::extract::{State, Path, Query};
|
||||
use axum::headers::{ContentType, CacheControl};
|
||||
use axum::TypedHeader;
|
||||
use bytes::Bytes;
|
||||
use crate::ServerState;
|
||||
use crate::state::db::Database;
|
||||
use crate::state::db::{Database, Thumbnail};
|
||||
use crate::routes::util::get_blob;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use ufh::item::ItemRef;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use sqlx::query as sql;
|
||||
|
||||
use super::{Response, Error};
|
||||
|
||||
|
@ -18,44 +18,53 @@ pub async fn route(
|
|||
State(state): State<Arc<ServerState>>,
|
||||
Path(item_ref): Path<ItemRef>,
|
||||
Query(params): Query<ThumbnailParams>,
|
||||
) -> Response<(TypedHeader<ContentType>, TypedHeader<CacheControl>, Vec<u8>)> {
|
||||
let item_ref_str = item_ref.to_string();
|
||||
let thumb_size = ThumbnailSize::closest_to(params.width, params.height);
|
||||
let thumb_size_str = thumb_size.to_string();
|
||||
let thumb = sql!("SELECT * FROM thumbnails WHERE ref = ? AND size = ?", item_ref_str, thumb_size_str)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?;
|
||||
) -> Response<(TypedHeader<ContentType>, TypedHeader<CacheControl>, Bytes)> {
|
||||
let size = ThumbnailSize::closest_to(params.width, params.height);
|
||||
let thumb = state.db.thumbnail_get(&item_ref, &size).await?;
|
||||
let cache_control = TypedHeader(CacheControl::new()
|
||||
.with_public()
|
||||
.with_max_age(Duration::from_secs(60 * 60 * 24)));
|
||||
let content_type = TypedHeader(ContentType::png());
|
||||
if let Some(thumb) = thumb {
|
||||
Ok((content_type, cache_control, thumb.blob))
|
||||
} else {
|
||||
let event = state.db.get_event(&item_ref).await?.ok_or(Error::NotFound)?;
|
||||
let blob = get_blob(&state.items, &event, params.via.as_deref()).await?;
|
||||
let image = image::load_from_memory(&blob)?;
|
||||
let (width, height) = thumb_size.get_dimensions();
|
||||
let thumb = image.thumbnail(width, height);
|
||||
let mut buffer = Cursor::new(Vec::new());
|
||||
thumb.write_to(&mut buffer, image::ImageFormat::Png)?;
|
||||
let data = buffer.into_inner();
|
||||
sql!("INSERT INTO thumbnails (ref, size, blob) VALUES (?, ?, ?)", item_ref_str, thumb_size_str, data)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok((content_type, cache_control, data))
|
||||
match thumb {
|
||||
Thumbnail::Raw(bytes) => {
|
||||
let thumb = generate_thumb(&bytes, &size)?;
|
||||
state.db.thumbnail_create(&item_ref, &size, &thumb).await?;
|
||||
Ok((content_type, cache_control, thumb))
|
||||
},
|
||||
Thumbnail::Some(thumb) => {
|
||||
Ok((content_type, cache_control, thumb))
|
||||
}
|
||||
Thumbnail::None => {
|
||||
let event = state.db.get_event(&item_ref).await?.ok_or(Error::NotFound)?;
|
||||
let blob = get_blob(&state.items, &event, params.via.as_deref()).await?;
|
||||
let thumb = generate_thumb(&blob, &size)?;
|
||||
state.db.thumbnail_create(&item_ref, &size, &thumb).await?;
|
||||
Ok((content_type, cache_control, thumb))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_thumb(bytes: &Bytes, size: &ThumbnailSize) -> Result<Bytes, Error> {
|
||||
let image = image::load_from_memory(bytes)?;
|
||||
let (width, height) = size.get_dimensions();
|
||||
let thumb = image.thumbnail(width, height);
|
||||
let mut buffer = Cursor::new(Vec::new());
|
||||
thumb.write_to(&mut buffer, image::ImageFormat::Png)?;
|
||||
Ok(Bytes::from(buffer.into_inner()))
|
||||
}
|
||||
|
||||
// TODO (maybe): no height/width -> return blob
|
||||
// eg. for getting the full thumbnail for a video without resizing
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ThumbnailParams {
|
||||
pub height: u32,
|
||||
pub width: u32,
|
||||
height: u32,
|
||||
width: u32,
|
||||
via: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
|
||||
pub enum ThumbnailSize {
|
||||
Raw,
|
||||
Small,
|
||||
Medium,
|
||||
Large,
|
||||
|
@ -67,6 +76,7 @@ impl ThumbnailSize {
|
|||
ThumbnailSize::Small => (64, 64),
|
||||
ThumbnailSize::Medium => (256, 256),
|
||||
ThumbnailSize::Large => (512, 512),
|
||||
ThumbnailSize::Raw => panic!("raw thumbnails shouldn't be generated"),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,6 +97,7 @@ impl Display for ThumbnailSize {
|
|||
ThumbnailSize::Small => "small",
|
||||
ThumbnailSize::Medium => "medium",
|
||||
ThumbnailSize::Large => "large",
|
||||
ThumbnailSize::Raw => "raw",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use bytes::Bytes;
|
||||
use ufh::derived::Derived;
|
||||
use ufh::item::ItemRef;
|
||||
use ufh::query::Query;
|
||||
use ufh::event::Event;
|
||||
|
||||
use crate::routes::things::thumbnail::ThumbnailSize;
|
||||
|
||||
// TODO: make a proper type for shares
|
||||
// type Share = String;
|
||||
|
||||
|
@ -32,6 +35,13 @@ pub enum DbItem {
|
|||
Blob,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Thumbnail {
|
||||
Raw(Bytes),
|
||||
Some(Bytes),
|
||||
None
|
||||
}
|
||||
|
||||
pub trait Database {
|
||||
type Error;
|
||||
|
||||
|
@ -50,9 +60,9 @@ pub trait Database {
|
|||
async fn derived_set(&self, item_ref: &ItemRef, derived: &Derived) -> Result<(), Self::Error>;
|
||||
|
||||
// thumbnails
|
||||
// async fn thumbnail_create(&self, item_ref: &ItemRef, size: &ThumbnailSize, bytes: &[u8]) -> Result<(), Self::Error>;
|
||||
// async fn thumbnail_get(&self, item_ref: &ItemRef, size: &ThumbnailSize) -> Result<Option<()>, Self::Error>;
|
||||
// async fn thumbnail_delete(&self, item_ref: &ItemRef, size: &ThumbnailSize) -> Result<(), Self::Error>;
|
||||
async fn thumbnail_create(&self, item_ref: &ItemRef, size: &ThumbnailSize, bytes: &[u8]) -> Result<(), Self::Error>;
|
||||
async fn thumbnail_get(&self, item_ref: &ItemRef, size: &ThumbnailSize) -> Result<Thumbnail, Self::Error>;
|
||||
async fn thumbnail_delete(&self, item_ref: &ItemRef, size: &ThumbnailSize) -> Result<(), Self::Error>;
|
||||
|
||||
// shares
|
||||
// async fn share_create(&self, item_ref: &ItemRef, share_id: Option<&str>, expires_at: Option<u64>) -> Result<(), Self::Error>;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::str::FromStr;
|
||||
|
||||
use bytes::Bytes;
|
||||
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions, QueryBuilder, Row};
|
||||
use sqlx::query as sql;
|
||||
use futures_util::TryStreamExt;
|
||||
|
@ -12,7 +13,8 @@ use ufh::event::EventContent;
|
|||
use ufh::{query, item::ItemRef};
|
||||
use tracing::debug;
|
||||
|
||||
use super::{Database, DbItem};
|
||||
use super::{Database, DbItem, Thumbnail};
|
||||
use crate::routes::things::thumbnail::ThumbnailSize;
|
||||
use crate::{Event, Error};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
@ -388,4 +390,36 @@ impl Database for Sqlite {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn thumbnail_create(&self, item_ref: &ItemRef, size: &ThumbnailSize, bytes: &[u8]) -> Result<(), Self::Error> {
|
||||
let item_ref_str = item_ref.to_string();
|
||||
let size_str = size.to_string();
|
||||
sql!("INSERT INTO thumbnails (ref, size, blob) VALUES (?, ?, ?)", item_ref_str, size_str, bytes)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn thumbnail_get(&self, item_ref: &ItemRef, size: &ThumbnailSize) -> Result<Thumbnail, Self::Error> {
|
||||
let item_ref_str = item_ref.to_string();
|
||||
let size_str = size.to_string();
|
||||
let result = sql!("SELECT * FROM thumbnails WHERE ref = ? AND size IN (?, 'raw')", item_ref_str, size_str)
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
match result {
|
||||
Ok(row) if row.size == "raw" => Ok(Thumbnail::Raw(Bytes::from(row.blob))),
|
||||
Ok(row) => Ok(Thumbnail::Some(Bytes::from(row.blob))),
|
||||
Err(sqlx::error::Error::RowNotFound) => return Ok(Thumbnail::None),
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn thumbnail_delete(&self, item_ref: &ItemRef, size: &ThumbnailSize) -> Result<(), Self::Error> {
|
||||
let item_ref_str = item_ref.to_string();
|
||||
let size_str = size.to_string();
|
||||
sql!("DELETE FROM thumbnails WHERE ref = ? AND size = ?", item_ref_str, size_str)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue