diff --git a/Cargo.lock b/Cargo.lock index 71d6d3e..fa96ad5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 7c98e1d..a3c12e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" \ No newline at end of file diff --git a/src/data/mod.rs b/src/data/mod.rs index b6e7973..4be379d 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -134,10 +134,20 @@ pub enum PhotoSize { Thumb, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] pub struct ThumbnailRequest { - pub path: String, - pub size: Option, + pub(crate) path: String, + pub(crate)size: Option, + #[serde(default)] + pub(crate)format: Option, +} + +#[derive(Debug, Deserialize, PartialEq)] +pub enum ThumbnailFormat { + #[serde(rename = "gif")] + Gif, + #[serde(rename = "image")] + Image, } #[derive(Deserialize)] diff --git a/src/files.rs b/src/files.rs index 1f3f22f..b71b0c3 100644 --- a/src/files.rs +++ b/src/files.rs @@ -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) ); } diff --git a/src/main.rs b/src/main.rs index 50cba73..aea239a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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()); diff --git a/src/state.rs b/src/state.rs index 5f6501c..0853787 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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 +} diff --git a/src/tags.rs b/src/tags.rs index 9c2ea59..276ffdb 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -299,7 +299,7 @@ impl TagDao for SqliteTagDao { ) -> anyhow::Result> { // 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> { - 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 { - 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> { - 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 { - 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, context: &opentelemetry::Context, ) -> anyhow::Result> { - 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, context: &opentelemetry::Context, ) -> anyhow::Result> { - 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, ) -> anyhow::Result> { 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> { 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 { 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> { @@ -654,7 +655,7 @@ mod tests { fn tag_file( &mut self, - context: &opentelemetry::Context, + _context: &opentelemetry::Context, path: &str, tag_id: i32, ) -> anyhow::Result { @@ -694,7 +695,7 @@ mod tests { &mut self, tag_ids: Vec, exclude_tag_ids: Vec, - context: &opentelemetry::Context, + _context: &opentelemetry::Context, ) -> anyhow::Result> { todo!() } @@ -703,7 +704,7 @@ mod tests { &mut self, tag_ids: Vec, exclude_tag_ids: Vec, - context: &opentelemetry::Context, + _context: &opentelemetry::Context, ) -> anyhow::Result> { 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"]; diff --git a/src/video.rs b/src/video/actors.rs similarity index 100% rename from src/video.rs rename to src/video/actors.rs diff --git a/src/video/ffmpeg.rs b/src/video/ffmpeg.rs new file mode 100644 index 0000000..d678153 --- /dev/null +++ b/src/video/ffmpeg.rs @@ -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 { + let ffmpeg_result: Result = 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 { + 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::() + .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 { + 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 { + 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)) + } +} \ No newline at end of file diff --git a/src/video/mod.rs b/src/video/mod.rs new file mode 100644 index 0000000..0d64712 --- /dev/null +++ b/src/video/mod.rs @@ -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); + }); +}