From ad5fdc5c8c19c328f12c2877cff8c9fd51fd1ed7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 26 Feb 2024 16:02:18 -0500 Subject: [PATCH] Framerate on video files --- Cargo.lock | 27 ++ Cargo.toml | 1 + .../down.sql | 5 + .../up.sql | 7 + _sql/migrations_squashed/media_files.sql | 3 + crates/lib/files/videos/Cargo.toml | 29 ++ crates/lib/files/videos/src/get_mp4_info.rs | 75 ++++++ crates/lib/files/videos/src/lib.rs | 21 ++ .../insert_media_file_from_file_upload.rs | 5 + crates/service/web/storyteller_web/Cargo.toml | 1 + .../endpoints/engine/create_scene_handler.rs | 3 +- .../engine/process_upload_media_file.rs | 1 + .../http_server/endpoints/media_files/mod.rs | 1 + .../upload/process_upload_media_file.rs | 1 + .../upload_video/drain_multipart_request.rs | 91 +++++++ .../endpoints/media_files/upload_video/mod.rs | 3 + .../media_files/upload_video/upload_error.rs | 37 +++ .../upload_video_media_file_handler.rs | 247 ++++++++++++++++++ .../service/web/storyteller_web/src/routes.rs | 4 + 19 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 _sql/migrations/2024-02-26-092932_alter_media_files_add_sample_rate/down.sql create mode 100644 _sql/migrations/2024-02-26-092932_alter_media_files_add_sample_rate/up.sql create mode 100644 crates/lib/files/videos/Cargo.toml create mode 100644 crates/lib/files/videos/src/get_mp4_info.rs create mode 100644 crates/lib/files/videos/src/lib.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/drain_multipart_request.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/mod.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/upload_error.rs create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/upload_video_media_file_handler.rs diff --git a/Cargo.lock b/Cargo.lock index ff4ae19f0d..70f3cb9a12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3934,6 +3934,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes 1.5.0", + "num-rational 0.4.1", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "my-workspace-hack" version = "0.1.0" @@ -4260,6 +4274,7 @@ dependencies = [ "num-bigint 0.4.4", "num-integer", "num-traits", + "serde", ] [[package]] @@ -6441,6 +6456,7 @@ dependencies = [ "utoipa", "utoipa-swagger-ui", "uuid 0.8.2", + "videos", ] [[package]] @@ -7771,6 +7787,17 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "videos" +version = "0.0.1" +dependencies = [ + "errors", + "log", + "mp4", + "my-workspace-hack", + "testing", +] + [[package]] name = "void" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 08bf080dea..745066ba2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ members = [ "crates/lib/files/images", "crates/lib/files/media", "crates/lib/files/mimetypes", + "crates/lib/files/videos", "crates/lib/idempotency", "crates/lib/jobs_common", "crates/lib/logging", diff --git a/_sql/migrations/2024-02-26-092932_alter_media_files_add_sample_rate/down.sql b/_sql/migrations/2024-02-26-092932_alter_media_files_add_sample_rate/down.sql new file mode 100644 index 0000000000..b108253ec5 --- /dev/null +++ b/_sql/migrations/2024-02-26-092932_alter_media_files_add_sample_rate/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE media_files DROP COLUMN maybe_sample_rate; diff --git a/_sql/migrations/2024-02-26-092932_alter_media_files_add_sample_rate/up.sql b/_sql/migrations/2024-02-26-092932_alter_media_files_add_sample_rate/up.sql new file mode 100644 index 0000000000..39abd18e8a --- /dev/null +++ b/_sql/migrations/2024-02-26-092932_alter_media_files_add_sample_rate/up.sql @@ -0,0 +1,7 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE media_files + ADD COLUMN maybe_sample_rate FLOAT DEFAULT NULL + AFTER maybe_duration_millis; diff --git a/_sql/migrations_squashed/media_files.sql b/_sql/migrations_squashed/media_files.sql index 79fa38ee21..535696325b 100644 --- a/_sql/migrations_squashed/media_files.sql +++ b/_sql/migrations_squashed/media_files.sql @@ -90,6 +90,9 @@ CREATE TABLE media_files ( -- The duration in millis, if audio or video. maybe_duration_millis INT(10) DEFAULT NULL, + -- Frame rate for video, sampling rate for audio (if present) + maybe_sample_rate FLOAT DEFAULT NULL, + -- Audio encoder details -- Only present if the file was audio or a video that had audio. maybe_audio_encoding VARCHAR(32) DEFAULT NULL, diff --git a/crates/lib/files/videos/Cargo.toml b/crates/lib/files/videos/Cargo.toml new file mode 100644 index 0000000000..e4dc227abd --- /dev/null +++ b/crates/lib/files/videos/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "videos" +edition = "2018" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +# NB: Public "videos" crate doesn't exist; "video" crate looks like it's not useful +name = "videos" +path = "src/lib.rs" + +[dependencies] + +# Internal +errors = { path = "../../errors" } + +# Workspace hack (faster compile times) +my-workspace-hack = { version = "0.1", path = "../../../my-workspace-hack" } + +# External +mp4 = "0.14.0" +log = "0.4.20" + +[dev-dependencies] +testing = { path = "../../testing" } diff --git a/crates/lib/files/videos/src/get_mp4_info.rs b/crates/lib/files/videos/src/get_mp4_info.rs new file mode 100644 index 0000000000..93f3f54f32 --- /dev/null +++ b/crates/lib/files/videos/src/get_mp4_info.rs @@ -0,0 +1,75 @@ +use std::io::{Read, Seek}; +use std::time::Duration; + +use log::warn; +use mp4::TrackType; + +use errors::AnyhowResult; + +pub struct Mp4Info { + /// Framerate of the longest video track. + pub framerate: f64, + /// Duration of the entire mp4. + pub duration_millis: u128, +} + +pub fn get_mp4_info(reader: T, file_size: u64) -> AnyhowResult { + let mp4 = mp4::Mp4Reader::read_header(reader, file_size)?; + + let mut framerate = 0.0; + let mut longest_duration = Duration::from_secs(0); + + for track in mp4.tracks().values() { + match track.track_type() { + Ok(TrackType::Video) => { + if track.duration() > longest_duration { + longest_duration = track.duration(); + framerate = track.frame_rate(); + } + } + Err(err) => { + warn!("Error determining track type: {:?}", err); + continue; + }, + _ => continue, + } + } + + Ok(Mp4Info { + framerate, + duration_millis: mp4.duration().as_millis(), + }) +} + +#[cfg(test)] +pub mod tests { + use std::fs::File; + use std::io::BufReader; + use std::path::PathBuf; + + use testing::test_file_path::test_file_path; + + use crate::get_mp4_info::get_mp4_info; + + fn test_file(path_from_repo_root: &str) -> PathBuf { + // https://doc.rust-lang.org/cargo/reference/environment-variables.html + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push(format!("../../../{}", path_from_repo_root)); + path + } + + #[test] + pub fn test_decode_mp4() { + let filename = test_file_path("test_data/video/mp4/golden_sun_garoh.mp4") + .expect("path should exist"); + + let file = File::open(filename).expect("file should open"); + let size = file.metadata().expect("should be able to grab metadata").len(); + let reader = BufReader::new(file); + + let info = get_mp4_info(reader, size).expect("mp4 reader should work"); + + assert_eq!(info.framerate, 30.0); + assert_eq!(info.duration_millis, 15168); + } +} diff --git a/crates/lib/files/videos/src/lib.rs b/crates/lib/files/videos/src/lib.rs new file mode 100644 index 0000000000..bc5578da2d --- /dev/null +++ b/crates/lib/files/videos/src/lib.rs @@ -0,0 +1,21 @@ +//! videos +//! +//! Library functions related to processing videos. +//! + +// Never allow these +#![forbid(private_bounds)] +#![forbid(private_interfaces)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +pub mod get_mp4_info; \ No newline at end of file diff --git a/crates/lib/mysql_queries/src/queries/media_files/create/insert_media_file_from_file_upload.rs b/crates/lib/mysql_queries/src/queries/media_files/create/insert_media_file_from_file_upload.rs index e1e439ff12..2c43a1b38d 100644 --- a/crates/lib/mysql_queries/src/queries/media_files/create/insert_media_file_from_file_upload.rs +++ b/crates/lib/mysql_queries/src/queries/media_files/create/insert_media_file_from_file_upload.rs @@ -34,6 +34,7 @@ pub struct InsertMediaFileFromUploadArgs<'a> { pub maybe_mime_type: Option<&'a str>, pub file_size_bytes: u64, pub duration_millis: u64, + pub maybe_sample_rate: Option, pub sha256_checksum: &'a str, // TODO: Media duration. @@ -98,6 +99,8 @@ SET maybe_mime_type = ?, file_size_bytes = ?, + maybe_sample_rate = ?, + checksum_sha2 = ?, public_bucket_directory_hash = ?, @@ -122,6 +125,8 @@ SET args.maybe_mime_type, args.file_size_bytes, + args.maybe_sample_rate, + args.sha256_checksum, args.public_bucket_directory_hash, diff --git a/crates/service/web/storyteller_web/Cargo.toml b/crates/service/web/storyteller_web/Cargo.toml index e1431af1d7..0db9fa5426 100644 --- a/crates/service/web/storyteller_web/Cargo.toml +++ b/crates/service/web/storyteller_web/Cargo.toml @@ -53,6 +53,7 @@ tts_common = { path = "../../../lib/tts_common" } twitch_common = { path = "../../../lib/api_clients/twitch_common" } url_config = { path = "../../../lib/url_config" } user_input_common = { path = "../../../lib/user_input_common" } +videos = { path = "../../../lib/files/videos" } # Internal Components billing_component = { path = "../../plugins/billing" } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/engine/create_scene_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/create_scene_handler.rs index d58743820e..6521a17e51 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/engine/create_scene_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/create_scene_handler.rs @@ -200,6 +200,7 @@ pub async fn create_scene_handler( media_file_type: MediaFileType::SceneRon, maybe_mime_type: Some(mime_type), file_size_bytes: file_size_bytes as u64, + maybe_sample_rate: None, duration_millis: 0, // None sha256_checksum: &hash, public_bucket_directory_hash: public_upload_path.get_object_hash(), @@ -209,7 +210,7 @@ pub async fn create_scene_handler( }) .await .map_err(|err| { - warn!("New generic download creation DB error: {:?}", err); + warn!("New file creation DB error: {:?}", err); CreateSceneError::ServerError })?; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/engine/process_upload_media_file.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/process_upload_media_file.rs index 2b74fe43e2..2e3649ddf9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/engine/process_upload_media_file.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/process_upload_media_file.rs @@ -332,6 +332,7 @@ pub async fn process_upload_media_file( media_file_type, maybe_mime_type: Some(mime_type), file_size_bytes: file_size_bytes as u64, + maybe_sample_rate: None, duration_millis: 0, // TODO sha256_checksum: &hash, public_bucket_directory_hash: public_upload_path.get_object_hash(), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs index 8e60c82234..3d090a165c 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs @@ -3,4 +3,5 @@ pub mod get_media_file; pub mod list; pub mod update_media_file; pub mod upload; +pub mod upload_video; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/process_upload_media_file.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/process_upload_media_file.rs index 2b74fe43e2..a6b57486a9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/process_upload_media_file.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/process_upload_media_file.rs @@ -333,6 +333,7 @@ pub async fn process_upload_media_file( maybe_mime_type: Some(mime_type), file_size_bytes: file_size_bytes as u64, duration_millis: 0, // TODO + maybe_sample_rate: None, sha256_checksum: &hash, public_bucket_directory_hash: public_upload_path.get_object_hash(), maybe_public_bucket_prefix: PREFIX, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/drain_multipart_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/drain_multipart_request.rs new file mode 100644 index 0000000000..708eb6cc86 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/drain_multipart_request.rs @@ -0,0 +1,91 @@ +use actix_multipart::Multipart; +use actix_web::web::BytesMut; +use futures::TryStreamExt; +use log::warn; + +use errors::AnyhowResult; +use tokens::tokens::zs_voice_datasets::ZsVoiceDatasetToken; + +use crate::http_server::web_utils::read_multipart_field_bytes::{checked_read_multipart_bytes, read_multipart_field_as_text}; + +pub struct MediaFileUploadData { + pub uuid_idempotency_token: Option, + pub file_name: Option, + pub file_bytes: Option, + pub media_source: MediaFileUploadSource, +} + +/// Where the frontend tells us the file came from. +#[derive(Eq, PartialEq, Clone, Copy)] +pub enum MediaFileUploadSource { + Unknown, + /// Eg. from the user's filesystem + UserFile, + /// Eg. the web audio API in Javascript. + UserDeviceApi, +} + +/// Pull common parts out of multipart media HTTP requests, typically for handling file uploads. +pub async fn drain_multipart_request(mut multipart_payload: Multipart) -> AnyhowResult { + let mut uuid_idempotency_token = None; + let mut file_bytes = None; + let mut file_name = None; + let mut media_source = None; + + while let Ok(Some(mut field)) = multipart_payload.try_next().await { + let mut field_name = None; + let mut field_filename = None; + + if let content_disposition = field.content_disposition() { + field_name = content_disposition.get_name() + .map(|s| s.to_string()); + field_filename = content_disposition.get_filename() // NB: Only used for the file bytes. + .map(|s| s.to_string()); + } + + match field_name.as_deref() { + Some("uuid_idempotency_token") => { + uuid_idempotency_token = read_multipart_field_as_text(&mut field).await + .map_err(|e| { + warn!("Error reading idempotency token: {:}", &e); + e + })?; + }, + Some("file") => { + file_name = field_filename.clone(); + file_bytes = checked_read_multipart_bytes(&mut field).await + .map_err(|e| { + warn!("Error reading audio upload: {:}", &e); + e + })?; + }, + Some("source") => { + media_source = read_multipart_field_as_text(&mut field).await + .map_err(|e| { + warn!("Error reading source: {:}", &e); + e + })?; + }, + _ => continue, + } + } + + let media_source = match media_source.as_deref() { + Some("device") => MediaFileUploadSource::UserDeviceApi, + Some("file") => MediaFileUploadSource::UserFile, + _ => { + if file_name.as_deref() == Some("blob") { + MediaFileUploadSource::UserDeviceApi + } else { + MediaFileUploadSource::Unknown + } + }, + }; + + Ok(MediaFileUploadData { + uuid_idempotency_token, + file_name, + file_bytes, + media_source, + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/mod.rs new file mode 100644 index 0000000000..e5f6a25137 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/mod.rs @@ -0,0 +1,3 @@ +pub mod drain_multipart_request; +pub mod upload_error; +pub mod upload_video_media_file_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/upload_error.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/upload_error.rs new file mode 100644 index 0000000000..aa0cc1e681 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/upload_error.rs @@ -0,0 +1,37 @@ +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::HttpResponse; +use utoipa::ToSchema; + +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; + +#[derive(Debug, Serialize, ToSchema)] +pub enum VideoMediaFileUploadError { + BadInput(String), + NotAuthorized, + MustBeLoggedIn, + ServerError, + RateLimited, +} + +impl ResponseError for VideoMediaFileUploadError { + fn status_code(&self) -> StatusCode { + match *self { + VideoMediaFileUploadError::BadInput(_) => StatusCode::BAD_REQUEST, + VideoMediaFileUploadError::NotAuthorized => StatusCode::UNAUTHORIZED, + VideoMediaFileUploadError::MustBeLoggedIn => StatusCode::UNAUTHORIZED, + VideoMediaFileUploadError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + VideoMediaFileUploadError::RateLimited => StatusCode::TOO_MANY_REQUESTS, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +impl std::fmt::Display for VideoMediaFileUploadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/upload_video_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/upload_video_media_file_handler.rs new file mode 100644 index 0000000000..6aeab5765d --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload_video/upload_video_media_file_handler.rs @@ -0,0 +1,247 @@ +use std::collections::HashSet; +use std::io::BufReader; +use std::sync::Arc; + +use actix_multipart::Multipart; +use actix_web::{HttpRequest, HttpResponse, web}; +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use utoipa::ToSchema; + +use buckets::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; +use http_server_common::request::get_request_ip::get_request_ip; +use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes; +use mimetypes::mimetype_to_extension::mimetype_to_extension; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::media_files::create::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType}; +use tokens::tokens::media_files::MediaFileToken; +use videos::get_mp4_info::get_mp4_info; + +use crate::http_server::endpoints::engine::create_scene_handler::{CreateSceneError, CreateSceneSuccessResponse}; +use crate::http_server::endpoints::media_files::upload_video::drain_multipart_request::drain_multipart_request; +use crate::http_server::endpoints::media_files::upload_video::drain_multipart_request::MediaFileUploadSource; +use crate::http_server::endpoints::media_files::upload_video::upload_error::VideoMediaFileUploadError; +use crate::server_state::ServerState; +use crate::validations::validate_idempotency_token_format::validate_idempotency_token_format; + +#[derive(Serialize, ToSchema)] +pub struct UploadVideoMediaSuccessResponse { + pub success: bool, + pub media_file_token: MediaFileToken, +} + +static ALLOWED_MIME_TYPES : Lazy> = Lazy::new(|| { + HashSet::from([ + // Video + "video/mp4", // NB: Only mp4 for now. + ]) +}); + +#[utoipa::path( + post, + path = "/v1/media_files/upload/video", + responses( + (status = 200, description = "Success Update", body = UploadVideoMediaSuccessResponse), + (status = 400, description = "Bad input", body = MediaFileUploadError), + (status = 401, description = "Not authorized", body = MediaFileUploadError), + (status = 429, description = "Too many requests", body = MediaFileUploadError), + (status = 500, description = "Server error", body = MediaFileUploadError), + ), + params( + ("request" = (), description = "Ask Brandon. This is form-multipart."), + ) +)] +pub async fn upload_video_media_file_handler( + http_request: HttpRequest, + server_state: web::Data>, + mut multipart_payload: Multipart, +) -> Result { + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + error!("MySql pool error: {:?}", err); + VideoMediaFileUploadError::ServerError + })?; + + // ==================== READ SESSION ==================== // + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .await + .map_err(|e| { + error!("Session checker error: {:?}", e); + VideoMediaFileUploadError::ServerError + })?; + + let maybe_avt_token = server_state + .avt_cookie_manager + .get_avt_token_from_request(&http_request); + + // ==================== BANNED USERS ==================== // + + if let Some(ref user) = maybe_user_session { + if user.is_banned { + return Err(VideoMediaFileUploadError::NotAuthorized); + } + } + + // ==================== RATE LIMIT ==================== // + + let rate_limiter = match maybe_user_session { + None => &server_state.redis_rate_limiters.file_upload_logged_out, + Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in, + }; + + if let Err(_err) = rate_limiter.rate_limit_request(&http_request) { + return Err(VideoMediaFileUploadError::RateLimited); + } + + // ==================== READ MULTIPART REQUEST ==================== // + + let upload_media_request = drain_multipart_request(multipart_payload) + .await + .map_err(|e| { + // TODO: Error handling could be nicer. + VideoMediaFileUploadError::BadInput("bad request".to_string()) + })?; + + // TODO(bt, 2024-02-26): This should be a transaction. + let uuid_idempotency_token = upload_media_request.uuid_idempotency_token + .ok_or(VideoMediaFileUploadError::BadInput("no uuid".to_string()))?; + + // ==================== HANDLE IDEMPOTENCY ==================== // + + if let Err(reason) = validate_idempotency_token_format(&uuid_idempotency_token) { + return Err(VideoMediaFileUploadError::BadInput(reason)); + } + + insert_idempotency_token(&uuid_idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + VideoMediaFileUploadError::BadInput("invalid idempotency token".to_string()) + })?; + + // ==================== UPLOAD METADATA ==================== // + + let creator_set_visibility = maybe_user_session + .as_ref() + .map(|user_session| user_session.preferred_tts_result_visibility) // TODO: We need a new type of visibility control. + .unwrap_or(Visibility::default()); + + let upload_type = match upload_media_request.media_source { + MediaFileUploadSource::Unknown => UploadType::Filesystem, + MediaFileUploadSource::UserFile => UploadType::Filesystem, + MediaFileUploadSource::UserDeviceApi => UploadType::DeviceCaptureApi, + }; + + // ==================== USER DATA ==================== // + + let ip_address = get_request_ip(&http_request); + + let maybe_user_token = maybe_user_session + .map(|session| session.get_strongly_typed_user_token()); + + // ==================== FILE DATA ==================== // + + let file_bytes = match upload_media_request.file_bytes { + None => return Err(VideoMediaFileUploadError::BadInput("missing file contents".to_string())), + Some(bytes) => bytes, + }; + + let mut maybe_mimetype = get_mimetype_for_bytes(&file_bytes); + + let mimetype = match maybe_mimetype { + None => return Err(VideoMediaFileUploadError::BadInput("unknown mimetype".to_string())), + Some(mimetype) => if ALLOWED_MIME_TYPES.contains(mimetype) { + mimetype + } else { + return Err(VideoMediaFileUploadError::BadInput("unsupported mimetype".to_string())); + } + }; + + let file_size_bytes = file_bytes.len(); + + let hash = sha256_hash_bytes(&file_bytes) + .map_err(|io_error| { + error!("Problem hashing bytes: {:?}", io_error); + VideoMediaFileUploadError::ServerError + })?; + + // ==================== FRAME RATE ==================== // + + let reader = BufReader::new(file_bytes.as_ref()); + + let mp4_info = get_mp4_info(&reader, file_size_bytes as u64) + .map_err(|err| { + warn!("Error reading mp4 info: {:?}", err); + VideoMediaFileUploadError::ServerError + })?; + + // ==================== UPLOAD AND SAVE ==================== // + + // TODO(bt,2024-02-26): We statically know the extension if it was an allowed mimetype. Fix this. + let mut extension = mimetype_to_extension(mimetype) + .map(|extension| format!(".{extension}")) + .unwrap_or(".mp4".to_string()); + + const PREFIX : Option<&str> = Some("video_"); + + let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, Some(&extension)); + + info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str()); + + server_state.public_bucket_client.upload_file_with_content_type( + public_upload_path.get_full_object_path_str(), + file_bytes.as_ref(), + mimetype) + .await + .map_err(|e| { + warn!("Upload media bytes to bucket error: {:?}", e); + VideoMediaFileUploadError::ServerError + })?; + + // TODO(bt, 2024-02-22): This should be a transaction. + let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs { + maybe_creator_user_token: maybe_user_token.as_ref(), + maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(), + creator_ip_address: &ip_address, + creator_set_visibility, + upload_type, + media_file_type: MediaFileType::Video, + maybe_mime_type: Some(mimetype), + file_size_bytes: file_size_bytes as u64, + maybe_sample_rate: Some(mp4_info.framerate as f32), + duration_millis: mp4_info.duration_millis as u64, + sha256_checksum: &hash, + public_bucket_directory_hash: public_upload_path.get_object_hash(), + maybe_public_bucket_prefix: PREFIX, + maybe_public_bucket_extension: Some(&extension), + pool: &server_state.mysql_pool, + }) + .await + .map_err(|err| { + warn!("New file creation DB error: {:?}", err); + VideoMediaFileUploadError::ServerError + })?; + + info!("new media file id: {} token: {:?}", record_id, &token); + + let response = CreateSceneSuccessResponse { + success: true, + media_file_token: token, + }; + + let body = serde_json::to_string(&response) + .map_err(|e| VideoMediaFileUploadError::ServerError)?; + + return Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)); +} diff --git a/crates/service/web/storyteller_web/src/routes.rs b/crates/service/web/storyteller_web/src/routes.rs index d435288570..562da7cd74 100644 --- a/crates/service/web/storyteller_web/src/routes.rs +++ b/crates/service/web/storyteller_web/src/routes.rs @@ -1087,6 +1087,10 @@ fn add_media_file_routes (app: App) -> App .route(web::post().to(upload_media_file_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + .service(web::resource("/upload/video") + .route(web::post().to(upload_media_file_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) ) }