diff --git a/server/chroma/Cargo.toml b/server/chroma/Cargo.toml index 22b35d9..674188a 100644 --- a/server/chroma/Cargo.toml +++ b/server/chroma/Cargo.toml @@ -29,6 +29,7 @@ img-parts = "0.3.0" time = "0.3.28" kamadak-exif = "0.5.5" cabbage = "0.1.2" +rand = "0.8.5" [dev-dependencies] serde_json = "1.0.93" diff --git a/server/chroma/src/routes/v1/photo/get.rs b/server/chroma/src/routes/v1/photo/get.rs index 32f935f..670f785 100644 --- a/server/chroma/src/routes/v1/photo/get.rs +++ b/server/chroma/src/routes/v1/photo/get.rs @@ -1,19 +1,13 @@ use crate::routes::appdata::WebData; use crate::routes::authorization::Authorization; use crate::routes::error::{Error, WebResult}; +use crate::routes::v1::photo::{get_prepare_image_data, ImageFormat}; use crate::routes::v1::PhotoQuality; use actix_multiresponse::Payload; use actix_web::web; use dal::database::Photo; -use dal::storage_engine::StorageEngineError; -use dal::DalError; -use image::{DynamicImage, ImageOutputFormat}; -use proto::photo_respone::Response; -use proto::{GetPhotoResponse, PhotoRespone}; +use proto::GetPhotoResponse; use serde::Deserialize; -use std::io::Cursor; -use tap::TapFallible; -use tracing::warn; #[derive(Debug, Deserialize)] pub struct Query { @@ -29,20 +23,12 @@ pub struct Query { format: ImageFormat, } -#[derive(Eq, PartialEq, Debug, Default, Deserialize)] -pub enum ImageFormat { - Png, - Jpeg, - #[default] - WebP, -} - /// Retrieve a photo by its ID. /// /// # Errors /// /// - If the photo does not exist -/// - If something went wrong +/// - If something went wrong whilst retrieving or preparing the photo pub async fn get( _: Authorization, data: WebData, @@ -52,83 +38,7 @@ pub async fn get( .await? .ok_or(Error::NotFound)?; - if query.format.eq(&ImageFormat::WebP) { - match photo - .clone() - .photo_to_proto_url(&data.storage, query.quality_preference.clone().into()) - .await - { - Ok(p) => { - return Ok(Payload(GetPhotoResponse { photo: Some(p) })); - } - Err(e) => match e { - DalError::Storage(e) => match e { - // URL mode is not supported - StorageEngineError::NotSupported => {} - _ => return Err(e.into()), - }, - DalError::Db(e) => return Err(e.into()), - }, - } - } - - let mut proto = photo - .photo_to_proto_bytes(&data.storage, query.quality_preference.clone().into()) - .await - .map_err(|e| match e { - DalError::Storage(e) => Error::from(e), - DalError::Db(e) => Error::from(e), - })?; - - let bytes = if let Response::Bytes(b) = proto.data.unwrap().response.unwrap() { - b - } else { - unreachable!() - }; - proto.data = Some(PhotoRespone { - response: Some(Response::Bytes(convert_format(bytes, &query.format)?)), - }); - + let proto = + get_prepare_image_data(&data, photo, &query.quality_preference, &query.format).await?; Ok(Payload(GetPhotoResponse { photo: Some(proto) })) } - -fn convert_format(bytes: Vec, format: &ImageFormat) -> WebResult> { - match format { - ImageFormat::WebP => Ok(bytes), - ImageFormat::Png => { - let byte_count = bytes.len(); - reencode_dynamic_image(decode_image(bytes)?, ImageOutputFormat::Png, byte_count) - } - ImageFormat::Jpeg => { - let byte_count = bytes.len(); - reencode_dynamic_image( - decode_image(bytes)?, - ImageOutputFormat::Jpeg(100), - byte_count, - ) - } - } -} - -fn reencode_dynamic_image( - image: DynamicImage, - format: ImageOutputFormat, - byte_count: usize, -) -> WebResult> { - let mut cursor = Cursor::new(Vec::with_capacity(byte_count)); - image - .write_to(&mut cursor, format) - .tap_err(|e| warn!("Failed to write image in format: {e}"))?; - - Ok(cursor.into_inner()) -} - -fn decode_image(bytes: Vec) -> WebResult { - match webp::Decoder::new(&bytes).decode() { - Some(webp) => Ok(webp.to_image()), - None => { - warn!("Failed to decode WebP image"); - Err(Error::WebpDecode) - } - } -} diff --git a/server/chroma/src/routes/v1/photo/mod.rs b/server/chroma/src/routes/v1/photo/mod.rs index 41b71c8..55c802d 100644 --- a/server/chroma/src/routes/v1/photo/mod.rs +++ b/server/chroma/src/routes/v1/photo/mod.rs @@ -1,11 +1,25 @@ +use crate::routes::appdata::AppData; +use crate::routes::error::{Error, WebResult}; use crate::routes::routable::Routable; +use crate::routes::v1::PhotoQuality; use actix_web::web; use actix_web::web::ServiceConfig; +use dal::database::Photo; +use dal::storage_engine::StorageEngineError; +use dal::DalError; +use image::{DynamicImage, ImageOutputFormat}; +use proto::photo_respone::Response; +use proto::PhotoRespone; +use serde::Deserialize; +use std::io::Cursor; +use tap::TapFallible; +use tracing::warn; mod create; mod delete; mod get; mod list; +mod random; pub struct Router; @@ -16,7 +30,129 @@ impl Routable for Router { .route("", web::post().to(create::create)) .route("", web::delete().to(delete::delete)) .route("", web::get().to(get::get)) - .route("/list", web::get().to(list::list)), + .route("/list", web::get().to(list::list)) + .route("/random", web::get().to(random::random)), ); } } + +#[derive(Eq, PartialEq, Debug, Default, Deserialize)] +pub enum ImageFormat { + Png, + Jpeg, + #[default] + WebP, +} + +/// Retrieve the image from storage and convert it into Protobuf format. +/// Adjusting for the requesting quality and image format. +/// +/// # Errors +/// +/// - If an IO error occurs +/// - If the storage engine fails +/// - If format conversion fails +async fn get_prepare_image_data( + data: &AppData, + photo: Photo<'_>, + quality: &PhotoQuality, + format: &ImageFormat, +) -> WebResult { + if format.eq(&ImageFormat::WebP) { + match photo + .clone() + .photo_to_proto_url(&data.storage, quality.clone().into()) + .await + { + Ok(p) => return Ok(p), + Err(e) => match e { + DalError::Storage(e) => match e { + // URL mode is not supported + StorageEngineError::NotSupported => {} + _ => return Err(e.into()), + }, + DalError::Db(e) => return Err(e.into()), + }, + } + } + + let mut proto = photo + .photo_to_proto_bytes(&data.storage, quality.clone().into()) + .await + .map_err(|e| match e { + DalError::Storage(e) => Error::from(e), + DalError::Db(e) => Error::from(e), + })?; + + let bytes = if let Response::Bytes(b) = proto.data.unwrap().response.unwrap() { + b + } else { + unreachable!() + }; + + proto.data = Some(PhotoRespone { + response: Some(Response::Bytes(convert_format(bytes, &format)?)), + }); + + Ok(proto) +} + +/// Convert an image from one format to the other. +/// The image in `bytes` must be the format specified in `format`. +/// +/// # Errors +/// +/// If decoding or encoding the image failed. +fn convert_format(bytes: Vec, format: &ImageFormat) -> WebResult> { + match format { + ImageFormat::WebP => Ok(bytes), + ImageFormat::Png => { + let byte_count = bytes.len(); + reencode_dynamic_image(decode_image(bytes)?, ImageOutputFormat::Png, byte_count) + } + ImageFormat::Jpeg => { + let byte_count = bytes.len(); + reencode_dynamic_image( + decode_image(bytes)?, + ImageOutputFormat::Jpeg(100), + byte_count, + ) + } + } +} + +/// Encode a DynamicImage to a specific format. +/// +/// ## Parameters +/// - `byte_count` is the number of bytes expected to be in the final image. +/// +/// # Errors +/// +/// If encoding the image failed. +fn reencode_dynamic_image( + image: DynamicImage, + format: ImageOutputFormat, + byte_count: usize, +) -> WebResult> { + let mut cursor = Cursor::new(Vec::with_capacity(byte_count)); + image + .write_to(&mut cursor, format) + .tap_err(|e| warn!("Failed to write image in format: {e}"))?; + + Ok(cursor.into_inner()) +} + +/// Decode raw WebP image bytes to an [image::DynamicImage] +/// +/// # Errors +/// +/// If the provided bytes don't form a valid WebP image +fn decode_image(bytes: Vec) -> WebResult { + match webp::Decoder::new(&bytes).decode() { + Some(webp) => Ok(webp.to_image()), + None => { + warn!("Failed to decode WebP image"); + Err(Error::WebpDecode) + } + } +} diff --git a/server/chroma/src/routes/v1/photo/random.rs b/server/chroma/src/routes/v1/photo/random.rs new file mode 100644 index 0000000..e273f54 --- /dev/null +++ b/server/chroma/src/routes/v1/photo/random.rs @@ -0,0 +1,79 @@ +use crate::routes::appdata::WebData; +use crate::routes::authorization::Authorization; +use crate::routes::error::{Error, WebResult}; +use crate::routes::v1::photo::{get_prepare_image_data, ImageFormat}; +use crate::routes::v1::PhotoQuality; +use actix_multiresponse::Payload; +use actix_web::http::StatusCode; +use actix_web::web; +use dal::database::Photo; +use proto::GetPhotoResponse; +use rand::Rng; +use serde::Deserialize; +use tap::TapOptional; +use tracing::warn; + +#[derive(Debug, Deserialize)] +pub struct Query { + /// The ID of the previous picture. + /// Providing this value will ensure the picture returned is + /// not the same picture as the picture with the ID provided. + previous_photo_id: Option, + /// A preference for the quality of the photo. + /// If the requested quality does not exist, the photo's original resolution will be returned. + #[serde(default)] + quality_preference: PhotoQuality, + /// The format of the image. + /// E.g., WebP or PNG + #[serde(default)] + format: ImageFormat, +} + +/// Get a random photo. +/// +/// # Errors +/// +/// - If something went wrong whilst retrieving or preparing the photo +pub async fn random( + _: Authorization, + data: WebData, + query: web::Query, +) -> WebResult> { + let ids = Photo::list_all_ids(&data.db) + .await? + .into_iter() + .filter(|x| match &query.previous_photo_id { + Some(q) => x.ne(q), + None => true, + }) + .collect::>(); + + let mut rng = rand::thread_rng(); + + let mut loop_count = 0; + let photo = loop { + let id = ids.get(rng.gen_range(0..ids.len())) + .tap_none(|| warn!("Index out of range: Random index is out of range for list of IDs. This should not be possible.")) + .ok_or(Error::Other(StatusCode::INTERNAL_SERVER_ERROR))?; + + let photo = Photo::get_by_id(&data.db, id).await?; + + match photo { + Some(p) => break p, + None => { + // This loop should really only run once. But it might run twice, + // but if we loop 5 times, something seriously wrong. Terminate + // the loop and return an error. + if loop_count > 5 { + return Err(Error::Other(StatusCode::INTERNAL_SERVER_ERROR)); + } + + loop_count += 1; + } + } + }; + + let proto = + get_prepare_image_data(&data, photo, &query.quality_preference, &query.format).await?; + Ok(Payload(GetPhotoResponse { photo: Some(proto) })) +} diff --git a/server/dal/src/database/photo.rs b/server/dal/src/database/photo.rs index e9af623..abc7e9f 100644 --- a/server/dal/src/database/photo.rs +++ b/server/dal/src/database/photo.rs @@ -131,6 +131,13 @@ impl<'a> Photo<'a> { Ok(photo.map(|photo| photo.into_photo(db))) } + pub async fn list_all_ids(db: &'a Database) -> DbResult> { + sqlx::query_scalar("SELECT id FROM photo_metadata") + .fetch_all(&**db) + .await + .map_err(|e| e.into()) + } + pub async fn delete(self) -> DbResult<()> { let mut tx = self.db.begin().await?; // Remove the photo from the album cover