diff --git a/.github/workflows/builld.yml b/.github/workflows/builld.yml new file mode 100644 index 0000000..31daf06 --- /dev/null +++ b/.github/workflows/builld.yml @@ -0,0 +1,23 @@ +name: "Build" + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - + name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/rust_test.yml b/.github/workflows/test_movies_db_service.yml similarity index 95% rename from .github/workflows/rust_test.yml rename to .github/workflows/test_movies_db_service.yml index 4ad7904..7660686 100644 --- a/.github/workflows/rust_test.yml +++ b/.github/workflows/test_movies_db_service.yml @@ -1,4 +1,4 @@ -name: "RustTest" +name: "RustServiceTest" on: pull_request: @@ -32,6 +32,9 @@ jobs: - name: "Check out the repo" uses: actions/checkout@v3 + - name: "Install ffmpeg" + run: sudo apt install -y ffmpeg + - uses: "actions-rs/toolchain@v1" with: profile: "minimal" diff --git a/docker/Dockerfile.movies-db-service b/docker/Dockerfile.movies-db-service index eff3c3a..83cd8fe 100644 --- a/docker/Dockerfile.movies-db-service +++ b/docker/Dockerfile.movies-db-service @@ -1,6 +1,6 @@ FROM ubuntu:22.04 -RUN apt update && apt upgrade -y && apt install -y curl +RUN apt update && apt upgrade -y && apt install -y curl ffmpeg COPY movies-db-service/target/release/movies-db-cli /opt/movies-db/bin/movies-db-cli COPY docker/service/start_movie_service.sh /opt/movies-db/bin/start_movie_service.sh diff --git a/movies-db-service/movies-db-cli/Cargo.toml b/movies-db-service/movies-db-cli/Cargo.toml index e58f202..4b477a4 100644 --- a/movies-db-service/movies-db-cli/Cargo.toml +++ b/movies-db-service/movies-db-cli/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" movies-db = { path = "../movies-db" } log = "0.4" anyhow = "1.0" -simple-logging = "2.0" +env_logger = "0.10" +chrono = { version = "0.4" } clap = { version = "4.2", features = ["derive"] } actix-web = "4" diff --git a/movies-db-service/movies-db-cli/src/main.rs b/movies-db-service/movies-db-cli/src/main.rs index 1bd7edb..c3f43e9 100644 --- a/movies-db-service/movies-db-cli/src/main.rs +++ b/movies-db-service/movies-db-cli/src/main.rs @@ -12,6 +12,8 @@ use clap::Parser; use log::LevelFilter; +use std::io::Write; + /// Parses the program arguments and returns None, if no arguments were provided and Some otherwise. fn parse_args() -> Result { let options = Options::parse(); @@ -20,7 +22,20 @@ fn parse_args() -> Result { /// Initializes the program logging fn initialize_logging(filter: LevelFilter) { - simple_logging::log_to(std::io::stdout(), filter); + env_logger::Builder::new() + .format(|buf, record| { + writeln!( + buf, + "{}:{} {} [{}] - {}", + record.file().unwrap_or("unknown"), + record.line().unwrap_or(0), + chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"), + record.level(), + record.args() + ) + }) + .filter_level(filter) + .init(); } /// Runs the program. diff --git a/movies-db-service/movies-db-cli/src/options.rs b/movies-db-service/movies-db-cli/src/options.rs index 6e13276..38878fc 100644 --- a/movies-db-service/movies-db-cli/src/options.rs +++ b/movies-db-service/movies-db-cli/src/options.rs @@ -35,12 +35,16 @@ pub struct Options { pub log_level: LogLevel, /// The address to bind the http server to - #[arg(short, value_enum, long, default_value = "0.0.0.0:3030")] + #[arg(short, long, default_value = "0.0.0.0:3030")] pub address: String, /// The path to the root directory #[arg(short, long)] pub root_dir: PathBuf, + + /// The path to where ffmpeg and ffprobe are located + #[arg(short, long, default_value = "/usr/bin/")] + pub ffmpeg: PathBuf, } impl From for ServiceOptions { @@ -48,6 +52,7 @@ impl From for ServiceOptions { ServiceOptions { root_dir: options.root_dir, http_address: options.address.parse().unwrap(), + ffmpeg: options.ffmpeg, } } } diff --git a/movies-db-service/movies-db/src/options.rs b/movies-db-service/movies-db/src/options.rs index b15fa0a..07ece09 100644 --- a/movies-db-service/movies-db/src/options.rs +++ b/movies-db-service/movies-db/src/options.rs @@ -7,6 +7,9 @@ pub struct Options { /// The address to bind the HTTP server to. pub http_address: SocketAddr, + + /// The path to where ffmpeg and ffprobe are located + pub ffmpeg: PathBuf, } impl Default for Options { @@ -14,6 +17,7 @@ impl Default for Options { Self { root_dir: PathBuf::from("./"), http_address: SocketAddr::from(([127, 0, 0, 1], 3030)), + ffmpeg: PathBuf::from("/usr/bin/"), } } } diff --git a/movies-db-service/movies-db/src/service/ffmpeg.rs b/movies-db-service/movies-db/src/service/ffmpeg.rs new file mode 100644 index 0000000..59d3a6c --- /dev/null +++ b/movies-db-service/movies-db/src/service/ffmpeg.rs @@ -0,0 +1,177 @@ +use std::path::{Path, PathBuf}; + +use log::{info, trace}; +use tokio::process::Command; + +use crate::Error; + +pub struct FFMpeg { + ffmpeg_bin_path: PathBuf, + ffprobe_bin_path: PathBuf, +} + +/// creates and returns the path to the ffmpeg binary. +/// +/// # Arguments +/// * `root_dir` - The path to the directory where ffmpeg is located. +fn create_ffmpeg_bin_path(ffmpeg_dir: &Path) -> PathBuf { + let mut ffmpeg_bin_path = ffmpeg_dir.to_path_buf(); + ffmpeg_bin_path.push("ffmpeg"); + + ffmpeg_bin_path +} + +/// creates and returns the path to the ffprobe binary. +/// +/// # Arguments +/// * `ffprobe_dir` - The path to the directory where ffprobe is located. +fn create_ffprobe_bin_path(ffprobe_dir: &Path) -> PathBuf { + let mut ffprobe_bin_path = ffprobe_dir.to_path_buf(); + ffprobe_bin_path.push("ffprobe"); + + ffprobe_bin_path +} + +impl FFMpeg { + /// Creates a new instance of ffmpeg. + /// + /// # Arguments + /// * `root_dir` - The path to the directory where the ffmpeg and ffprobe binaries are located. + pub async fn new(root_dir: &Path) -> Result { + let ffmpeg_bin_path = create_ffmpeg_bin_path(root_dir); + let ffprobe_bin_path = create_ffprobe_bin_path(root_dir); + + Self::check_bin(&ffmpeg_bin_path, "ffmpeg").await?; + Self::check_bin(&ffmpeg_bin_path, "ffprobe").await?; + + Ok(Self { + ffmpeg_bin_path, + ffprobe_bin_path, + }) + } + + /// Retur ns the duration of the given movie file in seconds. + pub async fn get_movie_duration(&self, movie_file: &Path) -> Result { + trace!("get_movie_duration: movie_file={}", movie_file.display()); + let output = Command::new(&self.ffprobe_bin_path) + .arg("-v") + .arg("error") + .arg("-show_entries") + .arg("format=duration") + .arg("-of") + .arg("default=noprint_wrappers=1:nokey=1") + .arg(movie_file) + .output() + .await + .map_err(|e| { + Error::Internal(format!( + "Failed to execute ffprobe binary '{}': {}", + self.ffprobe_bin_path.display(), + e + )) + })?; + + if !output.status.success() { + return Err(Error::Internal(format!( + "Failed to execute ffprobe binary '{}': {}", + self.ffprobe_bin_path.display(), + String::from_utf8_lossy(&output.stderr) + ))); + } + + let duration = String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .map_err(|e| { + Error::Internal(format!( + "Failed to parse ffprobe output '{}': {}", + String::from_utf8_lossy(&output.stdout), + e + )) + })?; + + Ok(duration) + } + + /// Creates a screenshot of the given movie file at the given timestamp. + /// + /// # Arguments + /// * `movie_file` - The path to the movie file. + /// * `timestamp` - The timestamp in seconds at which to create the screenshot. + pub async fn create_screenshot( + &self, + movie_file: &Path, + timestamp: f64, + ) -> Result, Error> { + // trigger ffmpeg to create a screenshot of the given movie file at the given timestamp + // and return the screenshot data in png format + let output = Command::new(&self.ffmpeg_bin_path) + .arg("-ss") + .arg(timestamp.to_string()) + .arg("-i") + .arg(movie_file) + .arg("-vframes") + .arg("1") + .arg("-q:v") + .arg("2") + .arg("-c:v") + .arg("png") + .arg("-f") + .arg("image2pipe") + .arg("-") + .output() + .await + .map_err(|e| { + Error::Internal(format!( + "Failed to execute ffmpeg binary '{}': {}", + self.ffmpeg_bin_path.display(), + e + )) + })?; + + if !output.status.success() { + return Err(Error::Internal(format!( + "Failed to execute ffmpeg binary '{}': {}", + self.ffmpeg_bin_path.display(), + String::from_utf8_lossy(&output.stderr) + ))); + } + + Ok(output.stdout) + } + + /// Checks either ffmpeg or ffprobe binary. + /// + /// # Arguments + /// * `bin` - The path to the binary to check. + /// * `name` - The name of the binary to check. + async fn check_bin(bin: &Path, name: &str) -> Result<(), Error> { + let output = Command::new(bin) + .arg("-version") + .output() + .await + .map_err(|e| { + Error::Internal(format!( + "Failed to execute ffmpeg binary '{}': {}", + bin.display(), + e + )) + })?; + + if !output.status.success() { + return Err(Error::Internal(format!( + "Failed to execute ffmpeg binary '{}': {}", + bin.display(), + String::from_utf8_lossy(&output.stderr) + ))); + } + + // extract first line of version info + let output = String::from_utf8_lossy(&output.stdout); + let output = output.lines().next().unwrap_or_default(); + + info!("{} Version Info: {}", name, output); + + Ok(()) + } +} diff --git a/movies-db-service/movies-db/src/service/mod.rs b/movies-db-service/movies-db/src/service/mod.rs index 842a6a4..d084b95 100644 --- a/movies-db-service/movies-db/src/service/mod.rs +++ b/movies-db-service/movies-db/src/service/mod.rs @@ -1,3 +1,5 @@ +pub mod ffmpeg; +mod preview_generator; mod service_handler; mod service_impl; diff --git a/movies-db-service/movies-db/src/service/preview_generator.rs b/movies-db-service/movies-db/src/service/preview_generator.rs new file mode 100644 index 0000000..c7678f7 --- /dev/null +++ b/movies-db-service/movies-db/src/service/preview_generator.rs @@ -0,0 +1,207 @@ +use std::sync::Arc; + +use log::{debug, error, info, trace}; +use tokio::{ + io::AsyncWriteExt, + sync::{mpsc, RwLock}, +}; + +use crate::{ + ffmpeg::FFMpeg, MovieDataType, MovieId, MovieSearchQuery, MovieStorage, MoviesIndex, + ScreenshotInfo, +}; + +/// The request to generate a preview. +#[derive(Clone, Debug)] +pub struct ScreenshotRequest { + /// The id of the movie to generate the preview for. + pub movie_id: MovieId, + pub ext: String, +} + +pub struct PreviewGenerator { + ffmpeg: FFMpeg, + index: Arc>, + storage: Arc>, + recv_preview: mpsc::UnboundedReceiver, + send_preview: mpsc::UnboundedSender, +} + +impl PreviewGenerator { + /// Creates a new instance of the preview generator. + /// + /// # Arguments + /// * `ffmpeg` - The ffmpeg instance. + /// * `index` - The movie index. + /// * `storage` - The movie storage. + pub fn new(ffmpeg: FFMpeg, index: Arc>, storage: Arc>) -> Self { + let (send_preview, recv_preview) = mpsc::unbounded_channel(); + + Self { + ffmpeg, + index, + storage, + recv_preview, + send_preview: send_preview.clone(), + } + } + + /// Returns the sender for preview requests. + pub fn get_preview_request_sender(&self) -> mpsc::UnboundedSender { + self.send_preview.clone() + } + + /// Runs the preview generator loop. + pub async fn run(&mut self) { + self.trigger_all_missing_previews().await; + + info!("Starting preview generator loop..."); + + while let Some(r) = self.recv_preview.recv().await { + debug!("Generating preview for request '{:?}'", r); + + let file_path = match self + .storage + .read() + .await + .get_file_path( + r.movie_id.clone(), + MovieDataType::MovieData { ext: r.ext.clone() }, + ) + .await + { + Err(err) => { + error!("Failed to get movie file path for movie '{}'", r.movie_id); + error!("Error: {}", err); + continue; + } + Ok(file_path) => match file_path { + None => { + error!("File paths are not supported by backend"); + continue; + } + Some(file_path) => file_path, + }, + }; + + debug!("Movie file path: {}", file_path.display()); + + // determine the total duration of the movie + trace!("Getting movie duration..."); + let duration = match self.ffmpeg.get_movie_duration(&file_path).await { + Err(err) => { + error!("Failed to get movie duration for movie '{}'", r.movie_id); + error!("Error: {}", err); + continue; + } + Ok(duration) => duration, + }; + + // we make the screenshot in the middle of the movie + let time_stamp = duration / 2.0; + let screenshot_data = match self.ffmpeg.create_screenshot(&file_path, time_stamp).await + { + Ok(data) => data, + Err(err) => { + error!("Failed to create screenshot for movie '{}'", r.movie_id); + error!("Error: {}", err); + continue; + } + }; + + // write screenshot data + trace!("Write screenshot data..."); + let mut writer = match self + .storage + .read() + .await + .write_movie_data( + r.movie_id.clone(), + MovieDataType::ScreenshotData { + ext: "png".to_owned(), + }, + ) + .await + { + Ok(writer) => writer, + Err(err) => { + error!("Failed to write screenshot data for movie '{}'", r.movie_id); + error!("Error: {}", err); + continue; + } + }; + + if let Err(err) = writer.write_all(&screenshot_data).await { + error!("Failed to write screenshot data for movie '{}'", r.movie_id); + error!("Error: {}", err); + continue; + } + + // update movie index about the new screenshot + trace!("Update movie index..."); + match self + .index + .write() + .await + .update_screenshot_info( + &r.movie_id, + ScreenshotInfo { + extension: "png".to_owned(), + mime_type: "image/png".to_owned(), + }, + ) + .await + { + Ok(_) => {} + Err(err) => { + error!("Failed to update movie index for movie '{}'", r.movie_id); + error!("Error: {}", err); + continue; + } + } + } + + info!("Preview generator loop stopped"); + } + + async fn trigger_all_missing_previews(&self) { + info!("Triggering all missing previews..."); + let index = self.index.read().await; + + let preview_request_sender = self.get_preview_request_sender(); + + let query: MovieSearchQuery = Default::default(); + let movie_ids = match index.search_movies(query).await { + Ok(movie_ids) => movie_ids, + Err(err) => { + error!("Failed to search movies"); + error!("Error: {}", err); + return; + } + }; + + for movie_id in movie_ids.iter() { + let movie = match index.get_movie(movie_id).await { + Ok(movie) => movie, + Err(err) => { + error!("Failed to get movie '{}'", movie_id); + error!("Error: {}", err); + continue; + } + }; + + if let Some(movie_file_info) = movie.movie_file_info { + if movie.screenshot_file_info.is_none() { + info!("Movie '{}' is missing a preview", movie_id); + if let Err(err) = preview_request_sender.send(ScreenshotRequest { + movie_id: movie_id.clone(), + ext: movie_file_info.extension, + }) { + error!("Failed to send preview request for movie '{}'", movie_id); + error!("Error: {}", err); + } + } + } + } + } +} diff --git a/movies-db-service/movies-db/src/service/service_handler.rs b/movies-db-service/movies-db/src/service/service_handler.rs index fb3f7b7..1fb8d96 100644 --- a/movies-db-service/movies-db/src/service/service_handler.rs +++ b/movies-db-service/movies-db/src/service/service_handler.rs @@ -1,5 +1,5 @@ use crate::{ - Error, Movie, MovieDataType, MovieId, MovieSearchQuery, MovieStorage, MoviesIndex, Options, + Error, Movie, MovieDataType, MovieId, MovieSearchQuery, MovieStorage, MoviesIndex, ReadResource, ScreenshotInfo, }; @@ -12,17 +12,22 @@ use futures::{StreamExt, TryStreamExt}; use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use std::sync::Arc; use tokio::io::AsyncWriteExt; +use tokio::sync::{mpsc, RwLock}; use tokio_util::io::ReaderStream; +use super::preview_generator::ScreenshotRequest; + pub struct ServiceHandler where I: MoviesIndex, S: MovieStorage, { - index: I, - storage: S, + index: Arc>, + storage: Arc>, + preview_requests: mpsc::UnboundedSender, } #[derive(Debug, Serialize, Deserialize)] @@ -39,21 +44,34 @@ where /// Creates a new instance of the service handler. /// /// # Arguments - /// * `options` - The options for the service handler. - pub fn new(options: Options) -> Result { - let index = I::new(&options)?; - let storage = S::new(&options)?; - - Ok(Self { index, storage }) + /// * `index` - The movies index. + /// * `storage` - The movie storage. + /// * `preview_requests` - The channel for sending preview requests. + pub async fn new( + index: Arc>, + storage: Arc>, + preview_requests: mpsc::UnboundedSender, + ) -> Result { + Ok(Self { + index, + storage, + preview_requests, + }) } /// Handles the request to add a new movie. /// /// # Arguments /// * `movie` - The movie to add. - pub async fn handle_add_movie(&mut self, movie: Movie) -> Result { - match self.index.add_movie(movie).await { - Ok(movie_id) => match self.storage.allocate_movie_data(movie_id.clone()).await { + pub async fn handle_add_movie(&self, movie: Movie) -> Result { + match self.index.write().await.add_movie(movie).await { + Ok(movie_id) => match self + .storage + .read() + .await + .allocate_movie_data(movie_id.clone()) + .await + { Ok(()) => Ok(movie_id), Err(err) => Self::handle_error(err), }, @@ -66,7 +84,7 @@ where /// # Arguments /// * `movie` - The movie to get. pub async fn handle_get_movie(&self, id: MovieId) -> Result { - match self.index.get_movie(&id).await { + match self.index.read().await.get_movie(&id).await { Ok(movie) => Ok(web::Json(movie)), Err(err) => Self::handle_error(err), } @@ -76,9 +94,9 @@ where /// /// # Arguments /// * `movie` - The movie to get. - pub async fn handle_delete_movie(&mut self, id: MovieId) -> Result { - match self.index.remove_movie(&id).await { - Ok(()) => match self.storage.remove_movie_data(id).await { + pub async fn handle_delete_movie(&self, id: MovieId) -> Result { + match self.index.write().await.remove_movie(&id).await { + Ok(()) => match self.storage.read().await.remove_movie_data(id).await { Ok(_) => Ok(actix_web::HttpResponse::Ok()), Err(err) => { error!("Error deleting movie: {}", err); @@ -95,7 +113,7 @@ where /// * `id` - The id of the movie to upload. /// * `multipart` - The multipart data of the movie. pub async fn handle_upload_movie( - &mut self, + &self, id: MovieId, mut multipart: Multipart, ) -> Result { @@ -155,6 +173,8 @@ where // open writer for storing movie data let mut writer = match self .storage + .read() + .await .write_movie_data(id.clone(), MovieDataType::MovieData { ext: ext.clone() }) .await { @@ -186,6 +206,8 @@ where // update the movie file info match self .index + .write() + .await .update_movie_file_info( &id, crate::MovieFileInfo { @@ -201,6 +223,13 @@ where return Err(actix_web::error::ErrorInternalServerError(err)); } } + + if let Err(err) = self.preview_requests.send(ScreenshotRequest { + movie_id: id.clone(), + ext: ext.clone(), + }) { + error!("Error sending preview request: {}", err); + } } info!("Uploading movie {} ... DONE", id); @@ -214,7 +243,7 @@ where /// * `id` - The id of the movie to upload a screenshot. /// * `multipart` - The multipart data of the screenshot. pub async fn handle_upload_screenshot( - &mut self, + &self, id: MovieId, mut multipart: Multipart, ) -> Result { @@ -274,6 +303,8 @@ where // open writer for storing screenshot data let mut writer = match self .storage + .read() + .await .write_movie_data( id.clone(), MovieDataType::ScreenshotData { ext: ext.clone() }, @@ -308,6 +339,8 @@ where // update the movie screenshot info match self .index + .write() + .await .update_screenshot_info( &id, ScreenshotInfo { @@ -338,7 +371,7 @@ where info!("Downloading movie {} ...", id); // get the movie file info, needed for requesting the movie data - let movie_file_info = match self.index.get_movie(&id).await { + let movie_file_info = match self.index.read().await.get_movie(&id).await { Ok(movie) => match movie.movie_file_info { Some(movie_file_info) => movie_file_info, None => { @@ -358,6 +391,8 @@ where // create reader onto the movie data let movie_data = match self .storage + .read() + .await .read_movie_data( id, MovieDataType::MovieData { @@ -391,7 +426,7 @@ where info!("Downloading screenshot {} ...", id); // get the movie screenshot info, needed for requesting the data - let screenshot_info = match self.index.get_movie(&id).await { + let screenshot_info = match self.index.read().await.get_movie(&id).await { Ok(movie) => match movie.screenshot_file_info { Some(screenshot_info) => screenshot_info, None => { @@ -411,6 +446,8 @@ where // create reader onto the screenshot data let screenshot_data = match self .storage + .read() + .await .read_movie_data( id, MovieDataType::ScreenshotData { @@ -438,7 +475,7 @@ where /// Handles the request to show the list of all movies. pub async fn handle_search_movies(&self, query: MovieSearchQuery) -> Result { - let movie_ids = match self.index.search_movies(query).await { + let movie_ids = match self.index.read().await.search_movies(query).await { Ok(movie_ids) => movie_ids, Err(err) => { error!("Error searching: {}", err); @@ -448,7 +485,7 @@ where let mut movies: Vec = Vec::with_capacity(movie_ids.len()); for movie_id in movie_ids.iter() { - let movie = self.index.get_movie(movie_id).await.unwrap(); + let movie = self.index.read().await.get_movie(movie_id).await.unwrap(); movies.push(MovieListEntry { id: movie_id.clone(), title: movie.movie.title, @@ -458,6 +495,10 @@ where Ok(web::Json(movies)) } + /// Handles the given error by translating it into an actix-web error response. + /// + /// # Arguments + /// * `err` - The error to handle. fn handle_error(err: Error) -> Result { match err { Error::InvalidArgument(e) => { diff --git a/movies-db-service/movies-db/src/service/service_impl.rs b/movies-db-service/movies-db/src/service/service_impl.rs index 4554a10..d5054c2 100644 --- a/movies-db-service/movies-db/src/service/service_impl.rs +++ b/movies-db-service/movies-db/src/service/service_impl.rs @@ -1,17 +1,20 @@ -use std::marker::PhantomData; +use std::{marker::PhantomData, sync::Arc}; use actix_cors::Cors; use actix_multipart::Multipart; use actix_web::{web, App, HttpServer, Responder, Result}; use log::{debug, error, info, trace}; -use tokio::sync::RwLock; +use tokio::sync::{mpsc, RwLock}; -use crate::{Error, Movie, MovieId, MovieSearchQuery, MovieStorage, MoviesIndex, Options}; +use crate::{ + ffmpeg::FFMpeg, service::preview_generator::PreviewGenerator, Error, Movie, MovieId, + MovieSearchQuery, MovieStorage, MoviesIndex, Options, +}; pub type BoxError = Box; -use super::service_handler::ServiceHandler; +use super::{preview_generator::ScreenshotRequest, service_handler::ServiceHandler}; use serde::{Deserialize, Serialize}; @@ -66,7 +69,24 @@ where /// Runs the HTTP server. async fn run_http_server(&self) -> Result<(), Error> { - let handler = self.create_service_handler()?; + let index = Arc::new(RwLock::new(I::new(&self.options)?)); + let storage = Arc::new(RwLock::new(S::new(&self.options)?)); + + // create preview generator + let ffmpeg = FFMpeg::new(&self.options.ffmpeg).await?; + let preview_generator = PreviewGenerator::new(ffmpeg, index.clone(), storage.clone()); + let preview_requests = preview_generator.get_preview_request_sender(); + + // spawn preview generator + tokio::spawn(async move { + let mut p = preview_generator; + p.run().await; + }); + + // create handler + let handler = self + .create_service_handler(index.clone(), storage.clone(), preview_requests) + .await?; let handler = RwLock::new(handler); let handler = web::Data::new(handler); @@ -107,19 +127,30 @@ where Err(err) => { error!("Running the HTTP server...FAILED"); error!("Error: {}", err); - Err(err.into()) + return Err(err.into()); } Ok(_) => { info!("Running the HTTP server...STOPPED"); - Ok(()) } } + + Ok(()) } /// Creates a new instance of the service handler. - fn create_service_handler(&self) -> Result, Error> { + /// + /// # Arguments + /// * `index` - The movies index. + /// * `storage` - The movie storage. + /// * `preview_requests` - The channel to send preview requests to. + async fn create_service_handler( + &self, + index: Arc>, + storage: Arc>, + preview_requests: mpsc::UnboundedSender, + ) -> Result, Error> { info!("Creating the service handler..."); - match ServiceHandler::new(self.options.clone()) { + match ServiceHandler::new(index, storage, preview_requests).await { Err(err) => { error!("Creating the service handler...FAILED"); error!("Error: {}", err); @@ -146,7 +177,7 @@ where let movie: Movie = movie.into_inner(); - let mut handler = handler.write().await; + let handler = handler.read().await; handler.handle_add_movie(movie).await } @@ -202,7 +233,7 @@ where let id: MovieId = query.into_inner().id; - let mut handler = handler.write().await; + let handler = handler.read().await; handler.handle_delete_movie(id).await } @@ -223,7 +254,7 @@ where let id: MovieId = query.into_inner().id; - let mut handler = handler.write().await; + let handler = handler.read().await; handler.handle_upload_movie(id, multipart).await } @@ -263,7 +294,7 @@ where let id: MovieId = query.into_inner().id; - let mut handler = handler.write().await; + let handler = handler.read().await; handler.handle_upload_screenshot(id, multipart).await } diff --git a/movies-db-service/movies-db/src/storage/file_storage.rs b/movies-db-service/movies-db/src/storage/file_storage.rs index 20c0f7b..86f116d 100644 --- a/movies-db-service/movies-db/src/storage/file_storage.rs +++ b/movies-db-service/movies-db/src/storage/file_storage.rs @@ -117,6 +117,14 @@ impl MovieStorage for FileStorage { Ok(()) } + + async fn get_file_path( + &self, + id: MovieId, + data_type: MovieDataType, + ) -> Result, Error> { + Ok(Some(self.get_file_path(&id, data_type, false).await?)) + } } impl FileStorage { diff --git a/movies-db-service/movies-db/src/storage/movies_storage.rs b/movies-db-service/movies-db/src/storage/movies_storage.rs index e3dbb5c..1962fa6 100644 --- a/movies-db-service/movies-db/src/storage/movies_storage.rs +++ b/movies-db-service/movies-db/src/storage/movies_storage.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use tokio::io::{AsyncRead, AsyncWrite}; use crate::{Error, MovieId, Options}; @@ -25,7 +27,7 @@ pub trait ReadResource: AsyncRead + Unpin + 'static { /// The trait for storing movie data. #[async_trait] pub trait MovieStorage: Send + Sync { - type W: AsyncWrite + Unpin; + type W: AsyncWrite + Unpin + Send; type R: ReadResource; /// Creates a new instance of the storage. @@ -69,4 +71,15 @@ pub trait MovieStorage: Send + Sync { id: MovieId, data_type: MovieDataType, ) -> Result; + + /// Returns a path to the resource if the movie storage can provide it. + /// + /// # Arguments + /// * `id` - The movie id for which to read the data. + /// * `data_type` - The type of data to read. + async fn get_file_path( + &self, + id: MovieId, + data_type: MovieDataType, + ) -> Result, Error>; } diff --git a/movies-db-service/movies-db/tests/data/file_example_MP4_480_1_5MG.mp4 b/movies-db-service/movies-db/tests/data/file_example_MP4_480_1_5MG.mp4 new file mode 100644 index 0000000..b11552f Binary files /dev/null and b/movies-db-service/movies-db/tests/data/file_example_MP4_480_1_5MG.mp4 differ diff --git a/movies-db-service/movies-db/tests/ffmpeg_test.rs b/movies-db-service/movies-db/tests/ffmpeg_test.rs new file mode 100644 index 0000000..8fdfbb2 --- /dev/null +++ b/movies-db-service/movies-db/tests/ffmpeg_test.rs @@ -0,0 +1,62 @@ +#[cfg(test)] +mod test { + use std::{ + fs::File, + io::Write, + path::{Path, PathBuf}, + }; + + use movies_db::ffmpeg::FFMpeg; + use tempdir::TempDir; + + fn write_file_to_temp_dir(temp_dir: &TempDir, file_name: &str, data: &[u8]) { + let mut file_path: PathBuf = temp_dir.path().to_owned(); + file_path.push(file_name); + File::create(&file_path).unwrap().write_all(data).unwrap(); + } + + #[tokio::test] + async fn test_ffmpeg_init() { + // test only works if ffmpeg and ffprobe are located in /usr/bin + FFMpeg::new(&Path::new("/usr/bin")).await.unwrap(); + } + + #[tokio::test] + async fn test_ffmpeg_duration() { + let temp_dir = TempDir::new("test_ffmpeg_version").unwrap(); + + // copy mp4 test file into temporary directory + let mp4_data = include_bytes!("data/file_example_MP4_480_1_5MG.mp4"); + + write_file_to_temp_dir(&temp_dir, "movie.mp4", mp4_data); + + // test only works if ffmpeg and ffprobe are located in /usr/bin + let ffmpeg = FFMpeg::new(&Path::new("/usr/bin")).await.unwrap(); + let duration = ffmpeg + .get_movie_duration(&temp_dir.path().join("movie.mp4")) + .await + .unwrap(); + + assert_eq!(duration as u32, 30); + } + + #[tokio::test] + async fn test_ffmpeg_screenshot() { + let temp_dir = TempDir::new("test_ffmpeg_version").unwrap(); + + // copy mp4 test file into temporary directory + let mp4_data = include_bytes!("data/file_example_MP4_480_1_5MG.mp4"); + + write_file_to_temp_dir(&temp_dir, "movie.mp4", mp4_data); + + // test only works if ffmpeg and ffprobe are located in /usr/bin + let ffmpeg = FFMpeg::new(&Path::new("/usr/bin")).await.unwrap(); + let screenshot = ffmpeg + .create_screenshot(&temp_dir.path().join("movie.mp4"), 15f64) + .await + .unwrap(); + + println!("{}", screenshot.len()); + println!("{:?}", String::from_utf8_lossy(&screenshot[..20])); + } +} diff --git a/movies-db-ui/src/components/VideoCard.tsx b/movies-db-ui/src/components/VideoCard.tsx index 9c492dd..b158d85 100644 --- a/movies-db-ui/src/components/VideoCard.tsx +++ b/movies-db-ui/src/components/VideoCard.tsx @@ -22,14 +22,14 @@ export interface VideoCardProps { export default function VideoCard(props: VideoCardProps): JSX.Element { const [movieInfo, setMovieInfo] = React.useState(null); - const [movieURL, setMovieURL] = React.useState(null); + const [previewURL, setPreviewURL] = React.useState(null); // try to load the movie info React.useEffect(() => { service.getMovie(props.movieId).then((movie) => { setMovieInfo(movie); - if (movie.movie_file_info) { - setMovieURL(service.getMovieUrl(props.movieId)); + if (movie.screenshot_file_info) { + setPreviewURL(service.getPreviewUrl(props.movieId)); } }); }, [props.movieId]); @@ -68,10 +68,10 @@ export default function VideoCard(props: VideoCardProps): JSX.Element { subheader={movieDate.toLocaleDateString() + ' ' + movieDate.toLocaleTimeString()} /> - {movieURL ? :