Add caching for thumbnails based and freedesktop.org spec
This commit is contained in:
parent
e4ffec63a1
commit
489eb2a066
5 changed files with 505 additions and 36 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -1507,6 +1507,7 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"libcosmic",
|
"libcosmic",
|
||||||
"log",
|
"log",
|
||||||
|
"md-5",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"notify-debouncer-full",
|
"notify-debouncer-full",
|
||||||
"notify-rust",
|
"notify-rust",
|
||||||
|
|
@ -1514,6 +1515,7 @@ dependencies = [
|
||||||
"open",
|
"open",
|
||||||
"ordermap",
|
"ordermap",
|
||||||
"paste",
|
"paste",
|
||||||
|
"png",
|
||||||
"procfs",
|
"procfs",
|
||||||
"recently-used-xbel",
|
"recently-used-xbel",
|
||||||
"regex",
|
"regex",
|
||||||
|
|
@ -4570,6 +4572,16 @@ dependencies = [
|
||||||
"rayon",
|
"rayon",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md-5"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.5"
|
version = "2.7.5"
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ slotmap = "1.0.7"
|
||||||
recently-used-xbel = "1.1.0"
|
recently-used-xbel = "1.1.0"
|
||||||
zip = "2.2.2"
|
zip = "2.2.2"
|
||||||
uzers = "0.12.1"
|
uzers = "0.12.1"
|
||||||
|
md-5 = "0.10.6"
|
||||||
|
png = "0.17.16"
|
||||||
|
|
||||||
# Completion-based IO runtime to enable io_uring / IOCP file IO support.
|
# Completion-based IO runtime to enable io_uring / IOCP file IO support.
|
||||||
[dependencies.compio]
|
[dependencies.compio]
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ use tab::Location;
|
||||||
|
|
||||||
use crate::config::State;
|
use crate::config::State;
|
||||||
pub mod tab;
|
pub mod tab;
|
||||||
|
mod thumbnail_cacher;
|
||||||
mod thumbnailer;
|
mod thumbnailer;
|
||||||
|
|
||||||
pub(crate) fn err_str<T: ToString>(err: T) -> String {
|
pub(crate) fn err_str<T: ToString>(err: T) -> String {
|
||||||
|
|
|
||||||
179
src/tab.rs
179
src/tab.rs
|
|
@ -62,8 +62,9 @@ use std::{
|
||||||
sync::{atomic, Arc, LazyLock, Mutex, RwLock},
|
sync::{atomic, Arc, LazyLock, Mutex, RwLock},
|
||||||
time::{Duration, Instant, SystemTime},
|
time::{Duration, Instant, SystemTime},
|
||||||
};
|
};
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use trash::{TrashItemMetadata, TrashItemSize};
|
use trash::TrashItemSize;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
@ -78,6 +79,7 @@ use crate::{
|
||||||
mounter::MOUNTERS,
|
mounter::MOUNTERS,
|
||||||
mouse_area,
|
mouse_area,
|
||||||
operation::Controller,
|
operation::Controller,
|
||||||
|
thumbnail_cacher::{CachedThumbnail, ThumbnailCacher, ThumbnailSize},
|
||||||
thumbnailer::thumbnailer,
|
thumbnailer::thumbnailer,
|
||||||
};
|
};
|
||||||
use uzers::{get_group_by_gid, get_user_by_uid};
|
use uzers::{get_group_by_gid, get_user_by_uid};
|
||||||
|
|
@ -1705,7 +1707,34 @@ impl Clone for ItemThumbnail {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ItemThumbnail {
|
impl ItemThumbnail {
|
||||||
pub fn new(path: &Path, metadata: ItemMetadata, mime: mime::Mime, thumbnail_size: u32) -> Self {
|
pub fn new(
|
||||||
|
path: &Path,
|
||||||
|
metadata: ItemMetadata,
|
||||||
|
mime: mime::Mime,
|
||||||
|
mut thumbnail_size: u32,
|
||||||
|
) -> Self {
|
||||||
|
let thumbnail_cacher =
|
||||||
|
ThumbnailCacher::new(path, ThumbnailSize::from_pixel_size(thumbnail_size));
|
||||||
|
match thumbnail_cacher.as_ref() {
|
||||||
|
Ok(cache) => match cache.get_cached_thumbnail() {
|
||||||
|
CachedThumbnail::Valid((path, size)) => {
|
||||||
|
return ItemThumbnail::Image(
|
||||||
|
widget::image::Handle::from_path(path),
|
||||||
|
size.map(|s| (s.pixel_size(), s.pixel_size())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
CachedThumbnail::Failed => {
|
||||||
|
return ItemThumbnail::NotImage;
|
||||||
|
}
|
||||||
|
CachedThumbnail::RequiresUpdate(size) => {
|
||||||
|
thumbnail_size = size.pixel_size();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("failed to create ThumbnailCache for {:?}: {}", path, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let size = metadata.file_size().unwrap_or_default();
|
let size = metadata.file_size().unwrap_or_default();
|
||||||
let check_size = |thumbnailer: &str, max_size| {
|
let check_size = |thumbnailer: &str, max_size| {
|
||||||
if size <= max_size {
|
if size <= max_size {
|
||||||
|
|
@ -1721,11 +1750,73 @@ impl ItemThumbnail {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut tried_supported_file = false;
|
||||||
|
|
||||||
|
// First try built-in image thumbnailer
|
||||||
|
if mime.type_() == mime::IMAGE && check_size("image", 64 * 1000 * 1000) {
|
||||||
|
tried_supported_file = true;
|
||||||
|
match image::ImageReader::open(path).and_then(|img| img.with_guessed_format()) {
|
||||||
|
Ok(reader) => match reader.decode() {
|
||||||
|
Ok(image) => {
|
||||||
|
if let Ok(cacher) = thumbnail_cacher.as_ref() {
|
||||||
|
match cacher.update_with_image(image) {
|
||||||
|
Ok(path) => {
|
||||||
|
return ItemThumbnail::Image(
|
||||||
|
widget::image::Handle::from_path(path),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("failed to decode {:?}: {}", path, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for when thumbnail cacher isn't available.
|
||||||
|
let thumbnail =
|
||||||
|
image.thumbnail(thumbnail_size, thumbnail_size).into_rgba8();
|
||||||
|
return ItemThumbnail::Image(
|
||||||
|
widget::image::Handle::from_rgba(
|
||||||
|
thumbnail.width(),
|
||||||
|
thumbnail.height(),
|
||||||
|
thumbnail.into_raw(),
|
||||||
|
),
|
||||||
|
Some((image.width(), image.height())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("failed to decode {:?}: {}", path, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("failed to read {:?}: {}", path, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try external thumbnailers.
|
||||||
|
let thumbnail_dir = thumbnail_cacher.as_ref().ok().map(|c| c.thumbnail_dir());
|
||||||
|
if let Some((item_thumbnail, temp_file)) =
|
||||||
|
Self::generate_thumbnail_external(path, &mime, thumbnail_size, thumbnail_dir)
|
||||||
|
{
|
||||||
|
if let Ok(cache) = thumbnail_cacher {
|
||||||
|
if let Err(err) = cache.update_with_temp_file(temp_file) {
|
||||||
|
log::warn!("failed to update cache for {:?}: {}", path, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item_thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
tried_supported_file = tried_supported_file || !thumbnailer(&mime).is_empty();
|
||||||
|
|
||||||
|
// Try internal thumbnailers that don't get cached.
|
||||||
//TODO: adjust limits for internal thumbnailers as desired
|
//TODO: adjust limits for internal thumbnailers as desired
|
||||||
if mime.type_() == mime::IMAGE
|
if mime.type_() == mime::IMAGE
|
||||||
&& mime.subtype() == mime::SVG
|
&& mime.subtype() == mime::SVG
|
||||||
&& check_size("svg", 8 * 1000 * 1000)
|
&& check_size("svg", 8 * 1000 * 1000)
|
||||||
{
|
{
|
||||||
|
tried_supported_file = true;
|
||||||
// Try built-in svg thumbnailer
|
// Try built-in svg thumbnailer
|
||||||
match fs::read(path) {
|
match fs::read(path) {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
|
|
@ -1736,30 +1827,6 @@ impl ItemThumbnail {
|
||||||
log::warn!("failed to read {:?}: {}", path, err);
|
log::warn!("failed to read {:?}: {}", path, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if mime.type_() == mime::IMAGE && check_size("image", 64 * 1000 * 1000) {
|
|
||||||
// Try built-in image thumbnailer
|
|
||||||
match image::ImageReader::open(path).and_then(|img| img.with_guessed_format()) {
|
|
||||||
Ok(reader) => match reader.decode() {
|
|
||||||
Ok(image) => {
|
|
||||||
let thumbnail =
|
|
||||||
image.thumbnail(thumbnail_size, thumbnail_size).into_rgba8();
|
|
||||||
return ItemThumbnail::Image(
|
|
||||||
widget::image::Handle::from_rgba(
|
|
||||||
thumbnail.width(),
|
|
||||||
thumbnail.height(),
|
|
||||||
thumbnail.into_raw(),
|
|
||||||
),
|
|
||||||
Some((image.width(), image.height())),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!("failed to decode {:?}: {}", path, err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!("failed to read {:?}: {}", path, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if mime.type_() == mime::TEXT && check_size("text", 8 * 1000 * 1000) {
|
} else if mime.type_() == mime::TEXT && check_size("text", 8 * 1000 * 1000) {
|
||||||
/*TODO: fix performance issues, widget::text_editr::Content::with_text forces all text to shape, which blocks rendering
|
/*TODO: fix performance issues, widget::text_editr::Content::with_text forces all text to shape, which blocks rendering
|
||||||
match fs::read_to_string(&path) {
|
match fs::read_to_string(&path) {
|
||||||
|
|
@ -1773,15 +1840,52 @@ impl ItemThumbnail {
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we weren't able to create a thumbnail, but we should have
|
||||||
|
// been able to, create a fail marker so that it isn't tried the
|
||||||
|
// next time.
|
||||||
|
if let Ok(cacher) = thumbnail_cacher {
|
||||||
|
if tried_supported_file {
|
||||||
|
if let Err(err) = cacher.create_fail_marker() {
|
||||||
|
log::warn!(
|
||||||
|
"failed to create thumbnail fail marker for {:?}: {}",
|
||||||
|
path,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemThumbnail::NotImage
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_thumbnail_external(
|
||||||
|
path: &Path,
|
||||||
|
mime: &mime::Mime,
|
||||||
|
thumbnail_size: u32,
|
||||||
|
thumbnail_dir: Option<&Path>,
|
||||||
|
) -> Option<(ItemThumbnail, NamedTempFile)> {
|
||||||
// Try external thumbnailers
|
// Try external thumbnailers
|
||||||
for thumbnailer in thumbnailer(&mime) {
|
for thumbnailer in thumbnailer(&mime) {
|
||||||
let prefix = if thumbnailer.exec.starts_with("evince-thumbnailer ") {
|
let is_evince = thumbnailer.exec.starts_with("evince-thumbnailer ");
|
||||||
|
let prefix = if is_evince {
|
||||||
//TODO: apparmor config for evince-thumbnailer does not allow /tmp/cosmic-files*
|
//TODO: apparmor config for evince-thumbnailer does not allow /tmp/cosmic-files*
|
||||||
"gnome-desktop-"
|
"gnome-desktop-"
|
||||||
} else {
|
} else {
|
||||||
"cosmic-files-"
|
"cosmic-files-"
|
||||||
};
|
};
|
||||||
let file = match tempfile::NamedTempFile::with_prefix(prefix) {
|
|
||||||
|
// It's preferable to create the tempfile in the same directory as the final cached
|
||||||
|
// thumbnail to ensure that no copies accross filesytems need to be made. However,
|
||||||
|
// the apparmor config for evince-thumbnailer does not allow this, so we need to
|
||||||
|
// fallback to the system tempdir.
|
||||||
|
let file = if thumbnail_dir.is_none() || is_evince {
|
||||||
|
tempfile::Builder::new().prefix(prefix).tempfile()
|
||||||
|
} else {
|
||||||
|
tempfile::Builder::new()
|
||||||
|
.prefix(prefix)
|
||||||
|
.tempfile_in(thumbnail_dir.unwrap())
|
||||||
|
};
|
||||||
|
let file = match file {
|
||||||
Ok(ok) => ok,
|
Ok(ok) => ok,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
|
|
@ -1804,14 +1908,17 @@ impl ItemThumbnail {
|
||||||
{
|
{
|
||||||
Ok(reader) => match reader.decode().map(|image| image.into_rgba8()) {
|
Ok(reader) => match reader.decode().map(|image| image.into_rgba8()) {
|
||||||
Ok(image) => {
|
Ok(image) => {
|
||||||
return ItemThumbnail::Image(
|
return Some((
|
||||||
widget::image::Handle::from_rgba(
|
ItemThumbnail::Image(
|
||||||
image.width(),
|
widget::image::Handle::from_rgba(
|
||||||
image.height(),
|
image.width(),
|
||||||
image.into_raw(),
|
image.height(),
|
||||||
|
image.into_raw(),
|
||||||
|
),
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
None,
|
file,
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::warn!("failed to decode {:?}: {}", path, err);
|
log::warn!("failed to decode {:?}: {}", path, err);
|
||||||
|
|
@ -1831,7 +1938,7 @@ impl ItemThumbnail {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ItemThumbnail::NotImage
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
347
src/thumbnail_cacher.rs
Normal file
347
src/thumbnail_cacher.rs
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
use image::DynamicImage;
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
error::Error,
|
||||||
|
fs::{self, File},
|
||||||
|
io::{self, BufReader, BufWriter},
|
||||||
|
os::unix::fs::PermissionsExt,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Implements thumbnail caching based on the freedesktop.org Thumbnail Managing Standard.
|
||||||
|
/// https://specifications.freedesktop.org/thumbnail-spec/latest/
|
||||||
|
pub struct ThumbnailCacher {
|
||||||
|
file_path: PathBuf,
|
||||||
|
file_uri: String,
|
||||||
|
thumbnail_dir: PathBuf,
|
||||||
|
thumbnail_path: PathBuf,
|
||||||
|
thumbnail_size: ThumbnailSize,
|
||||||
|
thumbnail_fail_marker_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThumbnailCacher {
|
||||||
|
pub fn new(file_path: &Path, thumbnail_size: ThumbnailSize) -> Result<Self, String> {
|
||||||
|
let file_uri = thumbnail_uri(file_path)
|
||||||
|
.map_err(|err| format!("failed to create URI for {:?}: {}", file_path, err))?;
|
||||||
|
let cache_base_dir = THUMBNAIL_CACHE_BASE_DIR
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(format!("failed to get thumbnail cache directory"))?;
|
||||||
|
let thumbnail_filename = thumbnail_cache_filename(&file_uri);
|
||||||
|
let thumbnail_dir = cache_base_dir.join(thumbnail_size.subdirectory_name());
|
||||||
|
let thumbnail_path = thumbnail_dir.join(&thumbnail_filename);
|
||||||
|
let thumbnail_fail_marker_path = cache_base_dir
|
||||||
|
.join("fail")
|
||||||
|
.join(format!("cosmic-files-{}", env!("CARGO_PKG_VERSION")))
|
||||||
|
.join(&thumbnail_filename);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
file_path: file_path.to_path_buf(),
|
||||||
|
file_uri,
|
||||||
|
thumbnail_dir,
|
||||||
|
thumbnail_path,
|
||||||
|
thumbnail_size,
|
||||||
|
thumbnail_fail_marker_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_cached_thumbnail(&self) -> CachedThumbnail {
|
||||||
|
// If the file is already a thumbnail, just use it so we don't generate
|
||||||
|
// cached thumbnails of thumbnails.
|
||||||
|
if let (Some(cache_base_dir), Ok(metadata)) = (
|
||||||
|
THUMBNAIL_CACHE_BASE_DIR.as_ref(),
|
||||||
|
std::fs::metadata(&self.file_path),
|
||||||
|
) {
|
||||||
|
if metadata.is_file() && self.file_path.starts_with(cache_base_dir) {
|
||||||
|
return CachedThumbnail::Valid((self.file_path.to_path_buf(), None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached thumbnail if it is valid.
|
||||||
|
if self.is_thumbnail_valid(&self.thumbnail_path) {
|
||||||
|
return CachedThumbnail::Valid((
|
||||||
|
self.thumbnail_path.clone(),
|
||||||
|
Some(self.thumbnail_size),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there is a fail marker from an earlier failure.
|
||||||
|
if self.is_thumbnail_valid(&self.thumbnail_fail_marker_path) {
|
||||||
|
return CachedThumbnail::Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
CachedThumbnail::RequiresUpdate(self.thumbnail_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn thumbnail_dir(&self) -> &Path {
|
||||||
|
&self.thumbnail_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_with_temp_file(&self, temp_file: NamedTempFile) -> Result<&Path, Box<dyn Error>> {
|
||||||
|
fs::set_permissions(temp_file.path(), fs::Permissions::from_mode(0o600))?;
|
||||||
|
self.update_thumbnail_text_metadata(temp_file.path())?;
|
||||||
|
fs::rename(temp_file.path(), &self.thumbnail_path)?;
|
||||||
|
|
||||||
|
Ok(&self.thumbnail_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_with_image(&self, image: DynamicImage) -> Result<&Path, Box<dyn Error>> {
|
||||||
|
let temp_file = tempfile::Builder::new()
|
||||||
|
.prefix("cosmic-files-")
|
||||||
|
.tempfile_in(&self.thumbnail_dir)?;
|
||||||
|
{
|
||||||
|
let file = File::create(temp_file.path())?;
|
||||||
|
let image = image
|
||||||
|
.thumbnail(
|
||||||
|
self.thumbnail_size.pixel_size(),
|
||||||
|
self.thumbnail_size.pixel_size(),
|
||||||
|
)
|
||||||
|
.into_rgba8();
|
||||||
|
let writer = BufWriter::new(file);
|
||||||
|
let mut encoder = png::Encoder::new(writer, image.width(), image.height());
|
||||||
|
encoder.set_color(png::ColorType::Rgba);
|
||||||
|
encoder.set_depth(png::BitDepth::Eight);
|
||||||
|
encoder
|
||||||
|
.write_header()?
|
||||||
|
.write_image_data(&image.into_raw())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update_with_temp_file(temp_file).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_fail_marker(&self) -> Result<(), Box<dyn Error>> {
|
||||||
|
if let Some(dir) = self.thumbnail_fail_marker_path.parent() {
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = File::create(&self.thumbnail_fail_marker_path)?;
|
||||||
|
let writer = BufWriter::new(file);
|
||||||
|
let mut encoder = png::Encoder::new(writer, 1, 1);
|
||||||
|
encoder.set_color(png::ColorType::Grayscale);
|
||||||
|
encoder.set_depth(png::BitDepth::One);
|
||||||
|
encoder.write_header()?.write_image_data(&[0])?;
|
||||||
|
self.update_thumbnail_text_metadata(&self.thumbnail_fail_marker_path)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_thumbnail_text_metadata(&self, path: &Path) -> Result<(), Box<dyn Error>> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let decoder = png::Decoder::new(reader);
|
||||||
|
let mut reader = decoder.read_info()?;
|
||||||
|
let (width, height, color_type, bit_depth, mut text_chunks) = {
|
||||||
|
let info = reader.info();
|
||||||
|
let text_chunks: HashMap<String, String> = info
|
||||||
|
.uncompressed_latin1_text
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|chunk| (chunk.keyword, chunk.text))
|
||||||
|
.collect();
|
||||||
|
(
|
||||||
|
info.width,
|
||||||
|
info.height,
|
||||||
|
info.color_type,
|
||||||
|
info.bit_depth,
|
||||||
|
text_chunks,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut image_data = vec![0; reader.output_buffer_size()];
|
||||||
|
reader.next_frame(&mut image_data)?;
|
||||||
|
|
||||||
|
let file = File::create(path)?;
|
||||||
|
let writer = BufWriter::new(file);
|
||||||
|
|
||||||
|
let mut encoder = png::Encoder::new(writer, width, height);
|
||||||
|
encoder.set_color(color_type);
|
||||||
|
encoder.set_depth(bit_depth);
|
||||||
|
|
||||||
|
text_chunks.insert("Software".to_string(), "COSMIC Files".to_string());
|
||||||
|
text_chunks.insert("Thumb::URI".to_string(), self.file_uri.clone());
|
||||||
|
let metadata = std::fs::metadata(&self.file_path)?;
|
||||||
|
let size = metadata.len();
|
||||||
|
text_chunks.insert("Thumb::Size".to_string(), size.to_string());
|
||||||
|
let mtime = metadata
|
||||||
|
.modified()?
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
text_chunks.insert("Thumb::MTime".to_string(), mtime.to_string());
|
||||||
|
|
||||||
|
for (keyword, text) in text_chunks {
|
||||||
|
encoder.add_text_chunk(keyword, text)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut writer = encoder.write_header()?;
|
||||||
|
writer.write_image_data(&image_data)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_thumbnail_valid(&self, thumbnail_path: &Path) -> bool {
|
||||||
|
let thumbnail_file = match File::open(thumbnail_path) {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let decoder = png::Decoder::new(BufReader::new(thumbnail_file));
|
||||||
|
let reader = match decoder.read_info() {
|
||||||
|
Ok(reader) => reader,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("failed to decode {:?} as PNG: {}", thumbnail_path, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let texts = &reader.info().uncompressed_latin1_text;
|
||||||
|
|
||||||
|
// Thumb::URI is required and must match.
|
||||||
|
let thumb_uri = texts
|
||||||
|
.iter()
|
||||||
|
.find(|text| text.keyword == "Thumb::URI")
|
||||||
|
.map(|t| &t.text);
|
||||||
|
if let Some(thumb_uri) = thumb_uri {
|
||||||
|
if *thumb_uri != self.file_uri {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = match std::fs::metadata(&self.file_path) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("failed to get metatdata of {:?}: {}", self.file_path, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Thumb::MTime is required and must match.
|
||||||
|
let thumb_mtime = texts
|
||||||
|
.iter()
|
||||||
|
.find(|text| text.keyword == "Thumb::MTime")
|
||||||
|
.map(|t| &t.text);
|
||||||
|
if let Some(thumb_mtime) = thumb_mtime {
|
||||||
|
let modified = match metadata.modified() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"failed to get modified from metatdata of {:?}, {}",
|
||||||
|
self.file_path,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mtime = modified
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
.to_string();
|
||||||
|
if *thumb_mtime != mtime {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumb::Size isn't required, but it should be verified if present.
|
||||||
|
let thumb_size = texts
|
||||||
|
.iter()
|
||||||
|
.find(|text| text.keyword == "Thumb::Size")
|
||||||
|
.map(|t| &t.text);
|
||||||
|
if let Some(thumb_size) = thumb_size {
|
||||||
|
let size = metadata.len();
|
||||||
|
if *thumb_size != size.to_string() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn thumbnail_uri(path: &Path) -> io::Result<String> {
|
||||||
|
let absolute_path = fs::canonicalize(&path)?;
|
||||||
|
let url = Url::from_file_path(&absolute_path).map_err(|_| {
|
||||||
|
io::Error::other(format!(
|
||||||
|
"failed to create URI for thumbnail_file: {:?}",
|
||||||
|
absolute_path
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
// Technically square brackets don't need to be percent encoded,
|
||||||
|
// and they aren't by the url crate, but the thumbnailer used by
|
||||||
|
// Gnome Files does. In order to share thumbnails and not get duplicates
|
||||||
|
// we should do the same.
|
||||||
|
let url = url.to_string().replace("[", "%5B").replace("]", "%5D");
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn thumbnail_cache_filename(file_uri: &str) -> String {
|
||||||
|
let hash = Md5::digest(file_uri);
|
||||||
|
format!("{:x}.png", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
#[repr(u32)]
|
||||||
|
pub enum ThumbnailSize {
|
||||||
|
Normal = 128,
|
||||||
|
Large = 256,
|
||||||
|
XLarge = 512,
|
||||||
|
XXLarge = 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThumbnailSize {
|
||||||
|
pub fn from_pixel_size(pixel_size: u32) -> Self {
|
||||||
|
if pixel_size <= Self::Normal.pixel_size() {
|
||||||
|
Self::Normal
|
||||||
|
} else if pixel_size <= Self::Large.pixel_size() {
|
||||||
|
Self::Large
|
||||||
|
} else if pixel_size <= Self::XLarge.pixel_size() {
|
||||||
|
Self::XLarge
|
||||||
|
} else {
|
||||||
|
Self::XXLarge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pixel_size(self) -> u32 {
|
||||||
|
self as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subdirectory_name(self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Normal => "normal".to_string(),
|
||||||
|
Self::Large => "large".to_string(),
|
||||||
|
Self::XLarge => "x-large".to_string(),
|
||||||
|
Self::XXLarge => "xx-large".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum CachedThumbnail {
|
||||||
|
/// The cached thumbnail is valid and should be used with size if known.
|
||||||
|
Valid((PathBuf, Option<ThumbnailSize>)),
|
||||||
|
/// The cached thumbnail doesn't exist or it's invalid and
|
||||||
|
/// needs to be recreated with the pixel size.
|
||||||
|
RequiresUpdate(ThumbnailSize),
|
||||||
|
// The cached thumbnail is in a failed state.
|
||||||
|
// This means it failed to create by cosmic-files in the past
|
||||||
|
// and shouldn't be tried again.
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
static THUMBNAIL_CACHE_BASE_DIR: Lazy<Option<PathBuf>> = Lazy::new(|| {
|
||||||
|
if let Ok(base_dirs) = xdg::BaseDirectories::new() {
|
||||||
|
let base_dir = base_dirs.get_cache_home().join("thumbnails");
|
||||||
|
return Some(base_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn!("failed to get thumbnail cache directory, thumbnails will not be cached");
|
||||||
|
|
||||||
|
None
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue