Merge pull request 'Video Gifs' (#34) from feature/video-gifs into master

Reviewed-on: #34
This commit was merged in pull request #34.
This commit is contained in:
2025-08-08 18:55:13 +00:00
10 changed files with 446 additions and 103 deletions

77
Cargo.lock generated
View File

@@ -320,7 +320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"getrandom",
"getrandom 0.2.15",
"once_cell",
"version_check",
"zerocopy",
@@ -599,7 +599,7 @@ checksum = "2b1866ecef4f2d06a0bb77880015fdf2b89e25a1c2e5addacb87e459c86dc67e"
dependencies = [
"base64 0.22.1",
"blowfish",
"getrandom",
"getrandom 0.2.15",
"subtle",
"zeroize",
]
@@ -1037,12 +1037,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "errno"
version = "0.3.9"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -1214,10 +1214,22 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
]
[[package]]
name = "gimli"
version = "0.31.1"
@@ -1632,6 +1644,7 @@ dependencies = [
"rayon",
"serde",
"serde_json",
"tempfile",
"tokio",
"walkdir",
]
@@ -1802,9 +1815,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.164"
version = "0.2.173"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f"
checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb"
[[package]]
name = "libfuzzer-sys"
@@ -1839,9 +1852,9 @@ dependencies = [
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
[[package]]
name = "litemap"
@@ -1974,7 +1987,7 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
@@ -1987,7 +2000,7 @@ dependencies = [
"hermit-abi",
"libc",
"log",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
]
@@ -2432,6 +2445,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
[[package]]
name = "rand"
version = "0.8.5"
@@ -2459,7 +2478,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.15",
]
[[package]]
@@ -2627,7 +2646,7 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.15",
"libc",
"spin",
"untrusted",
@@ -2651,15 +2670,15 @@ dependencies = [
[[package]]
name = "rustix"
version = "0.38.41"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6"
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
dependencies = [
"bitflags 2.6.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -2917,12 +2936,12 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tempfile"
version = "3.14.0"
version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c"
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
dependencies = [
"cfg-if",
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
@@ -3324,6 +3343,15 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasi"
version = "0.14.2+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
dependencies = [
"wit-bindgen-rt",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.95"
@@ -3606,6 +3634,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags 2.6.0",
]
[[package]]
name = "write16"
version = "1.0.0"

View File

@@ -42,3 +42,4 @@ opentelemetry_sdk = { version = "0.28.0", features = ["default", "rt-tokio-curre
opentelemetry-otlp = { version = "0.28.0", features = ["default", "metrics", "tracing", "grpc-tonic"] }
opentelemetry-stdout = "0.28.0"
opentelemetry-appender-log = "0.28.0"
tempfile = "3.20.0"

View File

@@ -134,10 +134,20 @@ pub enum PhotoSize {
Thumb,
}
#[derive(Deserialize)]
#[derive(Debug, Deserialize)]
pub struct ThumbnailRequest {
pub path: String,
pub size: Option<PhotoSize>,
pub(crate) path: String,
pub(crate)size: Option<PhotoSize>,
#[serde(default)]
pub(crate)format: Option<ThumbnailFormat>,
}
#[derive(Debug, Deserialize, PartialEq)]
pub enum ThumbnailFormat {
#[serde(rename = "gif")]
Gif,
#[serde(rename = "image")]
Image,
}
#[derive(Deserialize)]

View File

@@ -24,7 +24,7 @@ use crate::data::SortType::NameAsc;
use crate::error::IntoHttpError;
use crate::otel::{extract_context_from_request, global_tracer};
use crate::tags::{FileWithTagCount, TagDao};
use crate::video::StreamActor;
use crate::video::actors::StreamActor;
use path_absolutize::*;
use rand::prelude::SliceRandom;
use rand::thread_rng;
@@ -511,7 +511,6 @@ mod tests {
use crate::{
data::{Claims, PhotosResponse},
testhelpers::BodyReader,
video::StreamActor,
AppState,
};
@@ -519,6 +518,7 @@ mod tests {
use crate::tags::SqliteTagDao;
use actix_web::web::Data;
use std::{fs, sync::Arc};
use actix_web::test::TestRequest;
fn setup() {
let _ = env_logger::builder().is_test(true).try_init();
@@ -547,13 +547,9 @@ mod tests {
let response: HttpResponse = list_photos(
claims,
TestRequest::default().to_http_request(),
request,
Data::new(AppState::new(
Arc::new(StreamActor {}.start()),
String::from("/tmp"),
String::from("/tmp/thumbs"),
String::from("/tmp/video"),
)),
Data::new(AppState::test_state()),
Data::new(RealFileSystem::new(String::from("/tmp"))),
Data::new(Mutex::new(SqliteTagDao::default())),
)
@@ -589,14 +585,9 @@ mod tests {
let response = list_photos(
claims,
HttpRequest::default(),
TestRequest::default().to_http_request(),
request,
Data::new(AppState::new(
Arc::new(StreamActor {}.start()),
String::from("/tmp"),
String::from("/tmp/thumbs"),
String::from("/tmp/video"),
)),
Data::new(AppState::test_state()),
Data::new(RealFileSystem::new(String::from("./"))),
Data::new(Mutex::new(SqliteTagDao::default())),
)
@@ -618,12 +609,12 @@ mod tests {
let mut tag_dao = SqliteTagDao::new(in_memory_db_connection());
let tag1 = tag_dao.create_tag("tag1").unwrap();
let _tag2 = tag_dao.create_tag("tag2").unwrap();
let tag3 = tag_dao.create_tag("tag3").unwrap();
let tag1 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag1").unwrap();
let _tag2 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag2").unwrap();
let tag3 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag3").unwrap();
let _ = &tag_dao.tag_file("test.jpg", tag1.id).unwrap();
let _ = &tag_dao.tag_file("test.jpg", tag3.id).unwrap();
let _ = &tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", tag1.id).unwrap();
let _ = &tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", tag3.id).unwrap();
let mut files = HashMap::new();
files.insert(
@@ -637,14 +628,9 @@ mod tests {
let response: HttpResponse = list_photos(
claims,
HttpRequest::default(),
TestRequest::default().to_http_request(),
request,
Data::new(AppState::new(
Arc::new(StreamActor {}.start()),
String::from(""),
String::from("/tmp/thumbs"),
String::from("/tmp/video"),
)),
Data::new(AppState::test_state()),
Data::new(FakeFileSystem::new(files)),
Data::new(Mutex::new(tag_dao)),
)
@@ -668,15 +654,15 @@ mod tests {
let mut tag_dao = SqliteTagDao::new(in_memory_db_connection());
let tag1 = tag_dao.create_tag("tag1").unwrap();
let _tag2 = tag_dao.create_tag("tag2").unwrap();
let tag3 = tag_dao.create_tag("tag3").unwrap();
let tag1 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag1").unwrap();
let _tag2 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag2").unwrap();
let tag3 = tag_dao.create_tag(&opentelemetry::Context::current(), "tag3").unwrap();
let _ = &tag_dao.tag_file("test.jpg", tag1.id).unwrap();
let _ = &tag_dao.tag_file("test.jpg", tag3.id).unwrap();
let _ = &tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", tag1.id).unwrap();
let _ = &tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", tag3.id).unwrap();
// Should get filtered since it doesn't have tag3
tag_dao.tag_file("some-other.jpg", tag1.id).unwrap();
tag_dao.tag_file(&opentelemetry::Context::current(), "some-other.jpg", tag1.id).unwrap();
let mut files = HashMap::new();
files.insert(
@@ -696,13 +682,9 @@ mod tests {
let response: HttpResponse = list_photos(
claims,
TestRequest::default().to_http_request(),
request,
Data::new(AppState::new(
Arc::new(StreamActor {}.start()),
String::from(""),
String::from("/tmp/thumbs"),
String::from("/tmp/video"),
)),
Data::new(AppState::test_state()),
Data::new(FakeFileSystem::new(files)),
Data::new(Mutex::new(tag_dao)),
)
@@ -773,8 +755,8 @@ mod tests {
assert!(is_valid_full_path(&base, &test_file, false).is_some());
assert_eq!(
Some(PathBuf::from("/tmp/test.png")),
is_valid_full_path(&base, &PathBuf::from("/tmp/test.png"), false)
Some(PathBuf::from(test_file.clone())),
is_valid_full_path(&base, &PathBuf::from(test_file), false)
);
}

View File

@@ -42,7 +42,10 @@ use crate::otel::{extract_context_from_request, global_tracer};
use crate::service::ServiceBuilder;
use crate::state::AppState;
use crate::tags::*;
use crate::video::*;
use crate::video::actors::{
create_playlist, generate_video_thumbnail, ProcessMessage, ScanDirectoryMessage,
};
use crate::video::generate_video_gifs;
use log::{debug, error, info, trace, warn};
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use opentelemetry::{global, KeyValue};
@@ -96,23 +99,29 @@ async fn get_image(
.expect("Error stripping base path prefix from thumbnail");
let thumbs = &app_state.thumbnail_path;
let thumb_path = Path::new(&thumbs).join(relative_path);
let mut thumb_path = Path::new(&thumbs).join(relative_path);
// If it's a video and GIF format is requested, try to serve GIF thumbnail
if req.format == Some(ThumbnailFormat::Gif) && is_video_file(&path) {
thumb_path = Path::new(&app_state.gif_path).join(relative_path);
thumb_path.set_extension("gif");
}
trace!("Thumbnail path: {:?}", thumb_path);
if let Ok(file) = NamedFile::open(&thumb_path) {
span.set_status(Status::Ok);
file.into_response(&request)
} else {
span.set_status(Status::error("Not found"));
HttpResponse::NotFound().finish()
// The NamedFile will automatically set the correct content-type
return file.into_response(&request);
}
} else if let Ok(file) = NamedFile::open(path) {
span.set_status(Status::Ok);
file.into_response(&request)
} else {
span.set_status(Status::error("Not found"));
HttpResponse::NotFound().finish()
}
if let Ok(file) = NamedFile::open(&path) {
span.set_status(Status::Ok);
return file.into_response(&request);
}
span.set_status(Status::error("Not found"));
HttpResponse::NotFound().finish()
} else {
span.set_status(Status::error("Bad photos request"));
error!("Bad photos request: {}", req.path);
@@ -120,6 +129,17 @@ async fn get_image(
}
}
fn is_video_file(path: &Path) -> bool {
if let Some(extension) = path.extension() {
matches!(
extension.to_str().unwrap_or("").to_lowercase().as_str(),
"mp4" | "mov" | "avi" | "mkv"
)
} else {
false
}
}
#[get("/image/metadata")]
async fn get_file_metadata(
_: Claims,
@@ -589,6 +609,7 @@ fn main() -> std::io::Result<()> {
}
create_thumbnails();
generate_video_gifs().await;
let app_data = Data::new(AppState::default());

View File

@@ -1,5 +1,4 @@
use crate::video::{PlaylistGenerator, VideoPlaylistManager};
use crate::StreamActor;
use crate::video::actors::{PlaylistGenerator, StreamActor, VideoPlaylistManager};
use actix::{Actor, Addr};
use std::{env, sync::Arc};
@@ -9,6 +8,7 @@ pub struct AppState {
pub base_path: String,
pub thumbnail_path: String,
pub video_path: String,
pub gif_path: String,
}
impl AppState {
@@ -17,6 +17,7 @@ impl AppState {
base_path: String,
thumbnail_path: String,
video_path: String,
gif_path: String,
) -> Self {
let playlist_generator = PlaylistGenerator::new();
let video_playlist_manager =
@@ -28,8 +29,10 @@ impl AppState {
base_path,
thumbnail_path,
video_path,
gif_path,
}
}
}
impl Default for AppState {
@@ -39,6 +42,41 @@ impl Default for AppState {
env::var("BASE_PATH").expect("BASE_PATH was not set in the env"),
env::var("THUMBNAILS").expect("THUMBNAILS was not set in the env"),
env::var("VIDEO_PATH").expect("VIDEO_PATH was not set in the env"),
env::var("GIFS_DIRECTORY").expect("GIFS_DIRECTORY was not set in the env"),
)
}
}
#[cfg(test)]
impl AppState {
/// Creates an AppState instance for testing with temporary directories
pub fn test_state() -> Self {
use actix::Actor;
// Create a base temporary directory
let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
let base_path = temp_dir.path().to_path_buf();
// Create subdirectories for thumbnails, videos, and gifs
let thumbnail_path = create_test_subdir(&base_path, "thumbnails");
let video_path = create_test_subdir(&base_path, "videos");
let gif_path = create_test_subdir(&base_path, "gifs");
// Create the AppState with the temporary paths
AppState::new(
std::sync::Arc::new(crate::video::actors::StreamActor {}.start()),
base_path.to_string_lossy().to_string(),
thumbnail_path.to_string_lossy().to_string(),
video_path.to_string_lossy().to_string(),
gif_path.to_string_lossy().to_string(),
)
}
}
/// Helper function to create a subdirectory inside the base directory for testing
#[cfg(test)]
fn create_test_subdir(base_path: &std::path::Path, name: &str) -> std::path::PathBuf {
let dir_path = base_path.join(name);
std::fs::create_dir_all(&dir_path)
.expect(&format!("Failed to create {} directory", name));
dir_path
}

View File

@@ -299,7 +299,7 @@ impl TagDao for SqliteTagDao {
) -> anyhow::Result<Vec<(i64, Tag)>> {
// select name, count(*) from tags join tagged_photo ON tags.id = tagged_photo.tag_id GROUP BY tags.name ORDER BY COUNT(*);
trace_db_call(&context, "query", "get_all_tags", |span| {
trace_db_call(context, "query", "get_all_tags", |span| {
span.set_attribute(KeyValue::new("path", path.clone().unwrap_or_default()));
let path = path.map(|p| p + "%").unwrap_or("%".to_string());
@@ -334,7 +334,7 @@ impl TagDao for SqliteTagDao {
context: &opentelemetry::Context,
path: &str,
) -> anyhow::Result<Vec<Tag>> {
trace_db_call(&context, "query", "get_tags_for_path", |span| {
trace_db_call(context, "query", "get_tags_for_path", |span| {
span.set_attribute(KeyValue::new("path", path.to_string()));
debug!("Getting Tags for path: {:?}", path);
@@ -348,7 +348,7 @@ impl TagDao for SqliteTagDao {
}
fn create_tag(&mut self, context: &opentelemetry::Context, name: &str) -> anyhow::Result<Tag> {
trace_db_call(&context, "insert", "create_tag", |span| {
trace_db_call(context, "insert", "create_tag", |span| {
span.set_attribute(KeyValue::new("name", name.to_string()));
diesel::insert_into(tags::table)
@@ -386,7 +386,7 @@ impl TagDao for SqliteTagDao {
tag_name: &str,
path: &str,
) -> anyhow::Result<Option<()>> {
trace_db_call(&context, "delete", "remove_tag", |span| {
trace_db_call(context, "delete", "remove_tag", |span| {
span.set_attributes(vec![
KeyValue::new("tag_name", tag_name.to_string()),
KeyValue::new("path", path.to_string()),
@@ -421,7 +421,7 @@ impl TagDao for SqliteTagDao {
path: &str,
tag_id: i32,
) -> anyhow::Result<TaggedPhoto> {
trace_db_call(&context, "insert", "tag_file", |span| {
trace_db_call(context, "insert", "tag_file", |span| {
span.set_attributes(vec![
KeyValue::new("path", path.to_string()),
KeyValue::new("tag_id", tag_id.to_string()),
@@ -464,7 +464,7 @@ impl TagDao for SqliteTagDao {
exclude_tag_ids: Vec<i32>,
context: &opentelemetry::Context,
) -> anyhow::Result<Vec<FileWithTagCount>> {
trace_db_call(&context, "query", "get_files_with_all_tags", |_| {
trace_db_call(context, "query", "get_files_with_all_tags", |_| {
use diesel::dsl::*;
let exclude_subquery = tagged_photo::table
@@ -501,7 +501,7 @@ impl TagDao for SqliteTagDao {
exclude_tag_ids: Vec<i32>,
context: &opentelemetry::Context,
) -> anyhow::Result<Vec<FileWithTagCount>> {
trace_db_call(&context, "query", "get_files_with_any_tags", |_| {
trace_db_call(context, "query", "get_files_with_any_tags", |_| {
use diesel::dsl::*;
// Create the placeholders for the IN clauses
let tag_placeholders = std::iter::repeat("?")
@@ -553,6 +553,7 @@ impl TagDao for SqliteTagDao {
#[cfg(test)]
mod tests {
use actix_web::web::{Data, Json};
use actix_web::test::TestRequest;
use std::{cell::RefCell, collections::HashMap};
use diesel::result::Error::NotFound;
@@ -579,7 +580,7 @@ mod tests {
impl TagDao for TestTagDao {
fn get_all_tags(
&mut self,
context: &opentelemetry::Context,
_context: &opentelemetry::Context,
_option: Option<String>,
) -> anyhow::Result<Vec<(i64, Tag)>> {
Ok(self
@@ -593,7 +594,7 @@ mod tests {
fn get_tags_for_path(
&mut self,
context: &opentelemetry::Context,
_context: &opentelemetry::Context,
path: &str,
) -> anyhow::Result<Vec<Tag>> {
info!("Getting test tags for: {:?}", path);
@@ -609,7 +610,7 @@ mod tests {
fn create_tag(
&mut self,
context: &opentelemetry::Context,
_context: &opentelemetry::Context,
name: &str,
) -> anyhow::Result<Tag> {
self.tag_count += 1;
@@ -629,7 +630,7 @@ mod tests {
fn remove_tag(
&mut self,
context: &opentelemetry::Context,
_context: &opentelemetry::Context,
tag_name: &str,
path: &str,
) -> anyhow::Result<Option<()>> {
@@ -654,7 +655,7 @@ mod tests {
fn tag_file(
&mut self,
context: &opentelemetry::Context,
_context: &opentelemetry::Context,
path: &str,
tag_id: i32,
) -> anyhow::Result<TaggedPhoto> {
@@ -694,7 +695,7 @@ mod tests {
&mut self,
tag_ids: Vec<i32>,
exclude_tag_ids: Vec<i32>,
context: &opentelemetry::Context,
_context: &opentelemetry::Context,
) -> anyhow::Result<Vec<FileWithTagCount>> {
todo!()
}
@@ -703,7 +704,7 @@ mod tests {
&mut self,
tag_ids: Vec<i32>,
exclude_tag_ids: Vec<i32>,
context: &opentelemetry::Context,
_context: &opentelemetry::Context,
) -> anyhow::Result<Vec<FileWithTagCount>> {
todo!()
}
@@ -719,10 +720,11 @@ mod tests {
};
let tag_data = Data::new(Mutex::new(tag_dao));
add_tag(claims, web::Json(body), tag_data.clone()).await;
let request = TestRequest::default().to_http_request();
add_tag(claims, request, web::Json(body), tag_data.clone()).await;
let mut tag_dao = tag_data.lock().unwrap();
let tags = tag_dao.get_all_tags(None).unwrap();
let tags = tag_dao.get_all_tags(&opentelemetry::Context::current(), None).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags.first().unwrap().1.name, "test-tag");
let tagged_photos = tag_dao.tagged_photos.borrow();
@@ -744,11 +746,12 @@ mod tests {
};
let tag_data = Data::new(Mutex::new(tag_dao));
add_tag(claims.clone(), web::Json(add_request), tag_data.clone()).await;
remove_tagged_photo(claims, web::Json(remove_request), tag_data.clone()).await;
let request = TestRequest::default().to_http_request();
add_tag(claims.clone(), request.clone(), web::Json(add_request), tag_data.clone()).await;
remove_tagged_photo(claims, request, web::Json(remove_request), tag_data.clone()).await;
let mut tag_dao = tag_data.lock().unwrap();
let tags = tag_dao.get_all_tags(None).unwrap();
let tags = tag_dao.get_all_tags(&opentelemetry::Context::current(), None).unwrap();
assert!(tags.is_empty());
let tagged_photos = tag_dao.tagged_photos.borrow();
let previously_added_tagged_photo = tagged_photos.get("test.png").unwrap();
@@ -758,12 +761,12 @@ mod tests {
#[actix_rt::test]
async fn replace_tags_keeps_existing_tags_removes_extras_adds_missing_test() {
let mut tag_dao = TestTagDao::new();
let new_tag = tag_dao.create_tag("Test").unwrap();
let new_tag2 = tag_dao.create_tag("Test2").unwrap();
let _ = tag_dao.create_tag("Test3").unwrap();
let new_tag = tag_dao.create_tag(&opentelemetry::Context::current(), "Test").unwrap();
let new_tag2 = tag_dao.create_tag(&opentelemetry::Context::current(), "Test2").unwrap();
let _ = tag_dao.create_tag(&opentelemetry::Context::current(), "Test3").unwrap();
tag_dao.tag_file("test.jpg", new_tag.id).unwrap();
tag_dao.tag_file("test.jpg", new_tag2.id).unwrap();
tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", new_tag.id).unwrap();
tag_dao.tag_file(&opentelemetry::Context::current(), "test.jpg", new_tag2.id).unwrap();
let claims = Claims::valid_user(String::from("1"));
let tag_data = Data::new(Mutex::new(tag_dao));
@@ -773,7 +776,8 @@ mod tests {
file_name: String::from("test.jpg"),
};
update_tags(claims, tag_data.clone(), Json(add_tags_request)).await;
let request = TestRequest::default().to_http_request();
update_tags(claims, tag_data.clone(), request, web::Json(add_tags_request)).await;
let tag_dao = tag_data.lock().unwrap();
let tags_for_test_photo = &tag_dao.tagged_photos.borrow()["test.jpg"];

185
src/video/ffmpeg.rs Normal file
View File

@@ -0,0 +1,185 @@
use futures::TryFutureExt;
use log::{debug, error, info, warn};
use std::io::{Result};
use std::process::{Output, Stdio};
use std::time::Instant;
use tokio::process::Command;
pub struct Ffmpeg;
pub enum GifType {
Overview,
OverviewVideo { duration: u32 },
}
impl Ffmpeg {
async fn _generate_playlist(&self, input_file: &str, output_file: &str) -> Result<String> {
let ffmpeg_result: Result<Output> = Command::new("ffmpeg")
.arg("-i")
.arg(input_file)
.arg("-c:v")
.arg("h264")
.arg("-crf")
.arg("21")
.arg("-preset")
.arg("veryfast")
.arg("-hls_time")
.arg("3")
.arg("-hls_list_size")
.arg("100")
.arg("-vf")
.arg("scale=1080:-2,setsar=1:1")
.arg(output_file)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.inspect_err(|e| error!("Failed to run ffmpeg on child process: {}", e))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
.await;
if let Ok(ref res) = ffmpeg_result {
debug!("ffmpeg output: {:?}", res);
}
ffmpeg_result.map(|_| output_file.to_string())
}
async fn get_video_duration(&self, input_file: &str) -> Result<u32> {
Command::new("ffprobe")
.args(["-i", input_file])
.args(["-show_entries", "format=duration"])
.args(["-v", "quiet"])
.args(["-of", "csv=p=0"])
.output()
.await
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.inspect(|duration| debug!("Found video duration: {:?}", duration))
.and_then(|duration| {
duration
.parse::<f32>()
.map(|duration| duration as u32)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
})
.inspect(|duration| debug!("Found video duration: {:?}", duration))
}
pub async fn generate_video_gif(
&self,
input_file: &str,
output_file: &str,
gif_type: GifType,
) -> Result<String> {
info!("Creating gif for: '{}'", input_file);
match gif_type {
GifType::Overview => {
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir
.path()
.to_str()
.expect("Unable to make temp_dir a string");
match self
.get_video_duration(input_file)
.and_then(|duration| {
debug!("Creating gif frames for '{}'", input_file);
Command::new("ffmpeg")
.args(["-i", input_file])
.args(["-vf", &format!("fps=20/{}", duration)])
.args(["-q:v", "2"])
.stderr(Stdio::null())
.arg(format!("{}/frame_%03d.jpg", temp_path))
.status()
})
.and_then(|_| {
debug!("Generating palette");
Command::new("ffmpeg")
.args(["-i", &format!("{}/frame_%03d.jpg", temp_path)])
.args(["-vf", "palettegen"])
.arg(format!("{}/palette.png", temp_path))
.stderr(Stdio::null())
.status()
})
.and_then(|_| {
debug!("Creating gif for: '{}'", input_file);
self.create_gif_from_frames(temp_path, output_file)
})
.await
{
Ok(exit_code) => {
if exit_code == 0 {
info!("Created gif for '{}' -> '{}'", input_file, output_file);
} else {
warn!(
"Failed to create gif for '{}' with exit code: {}",
input_file, exit_code
);
}
}
Err(e) => {
error!("Error creating gif for '{}': {:?}", input_file, e);
}
}
}
GifType::OverviewVideo { duration } => {
let start = Instant::now();
match self
.get_video_duration(input_file)
.and_then(|input_duration| {
Command::new("ffmpeg")
.args(["-i", input_file])
.args([
"-vf",
// Grab 1 second of frames equally spaced to create a 'duration' second long video scaled to 720px on longest side
&format!(
"select='lt(mod(t,{}),1)',setpts=N/FRAME_RATE/TB,scale='if(gt(iw,ih),720,-2)':'if(gt(ih,iw),720,-2)",
input_duration / duration
),
])
.arg("-an")
.arg(output_file)
.status()
})
.await
{
Ok(out) => info!("Finished clip '{}' with code {:?} in {:?}", output_file, out.code(), start.elapsed()),
Err(e) => error!("Error creating video overview: {}", e),
}
}
}
Ok(output_file.to_string())
}
async fn create_gif_from_frames(&self, frame_base_dir: &str, output_file: &str) -> Result<i32> {
let output = Command::new("ffmpeg")
.arg("-y")
.args(["-framerate", "4"])
.args(["-i", &format!("{}/frame_%03d.jpg", frame_base_dir)])
.args(["-i", &format!("{}/palette.png", frame_base_dir)])
.args([
"-filter_complex",
// Scale to 480x480 with a center crop
"[0:v]scale=480:-1:flags=lanczos,crop='min(in_w,in_h)':'min(in_w,in_h)':(in_w-out_w)/2:(in_h-out_h)/2, paletteuse",
])
.args(["-loop", "0"]) // loop forever
.args(["-final_delay", "75"])
.arg(output_file)
.stderr(Stdio::piped()) // Change this to capture stderr
.stdout(Stdio::piped()) // Optionally capture stdout too
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
error!("FFmpeg error: {}", stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
debug!("FFmpeg stdout: {}", stdout);
} else {
debug!("FFmpeg successful with exit code: {}", output.status);
}
Ok(output.status.code().unwrap_or(-1))
}
}

65
src/video/mod.rs Normal file
View File

@@ -0,0 +1,65 @@
use crate::otel::global_tracer;
use crate::video::ffmpeg::{Ffmpeg, GifType};
use crate::{is_video, update_media_counts};
use log::{info};
use opentelemetry::trace::{Tracer};
use std::path::{Path, PathBuf};
use std::{fs};
use walkdir::WalkDir;
pub mod actors;
pub mod ffmpeg;
pub async fn generate_video_gifs() {
tokio::spawn(async {
info!("Starting to make video gifs");
let start = std::time::Instant::now();
let tracer = global_tracer();
tracer.start("creating video gifs");
let gif_base_path = &dotenv::var("GIFS_DIRECTORY").unwrap_or(String::from("gifs"));
let gif_directory: &Path = Path::new(gif_base_path);
fs::create_dir_all(gif_base_path).expect("There was an issue creating directory");
let files = PathBuf::from(dotenv::var("BASE_PATH").unwrap());
let ffmpeg = Ffmpeg;
for file in WalkDir::new(&files)
.into_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file())
.filter(is_video)
.filter(|entry| {
let path = entry.path();
let relative_path = &path.strip_prefix(&files).unwrap();
let thumb_path = Path::new(gif_directory).join(relative_path);
let gif_path = thumb_path.with_extension("gif");
!gif_path.exists()
})
{
let path = file.path();
let relative_path = &path.strip_prefix(&files).unwrap();
let gif_path = Path::new(gif_directory).join(relative_path);
let gif_path = gif_path.with_extension("gif");
if let Some(parent_dir) = gif_path.parent() {
fs::create_dir_all(parent_dir).unwrap_or_else(|_| panic!("There was an issue creating gif directory {:?}",
gif_path));
}
info!("Generating gif for {:?}", path);
ffmpeg
.generate_video_gif(
path.to_str().unwrap(),
gif_path.to_str().unwrap(),
GifType::Overview,
)
.await
.expect("There was an issue generating the gif");
}
info!("Finished making video gifs in {:?}", start.elapsed());
update_media_counts(&files);
});
}