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:
77
Cargo.lock
generated
77
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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)]
|
||||
|
||||
64
src/files.rs
64
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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
45
src/main.rs
45
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());
|
||||
|
||||
|
||||
42
src/state.rs
42
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
|
||||
}
|
||||
|
||||
54
src/tags.rs
54
src/tags.rs
@@ -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
185
src/video/ffmpeg.rs
Normal 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
65
src/video/mod.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user