diff --git a/crates/api_clients/artcraft/artcraft_client/src/error/api_error.rs b/crates/api_clients/artcraft/artcraft_client/src/error/api_error.rs index ea92b62b21..cca046f703 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/error/api_error.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/error/api_error.rs @@ -1,5 +1,6 @@ use cloudflare_errors::cloudflare_error::CloudflareError; use errors::AnyhowError; +use reqwest::StatusCode; use std::error::Error; use std::fmt::{Display, Formatter}; use std::io; @@ -30,6 +31,12 @@ pub enum ApiError { backend_hostname: Option, }, + /// An uncategorized bad HTTP response with status code and body. + UncategorizedBadResponseWithStatusAndBody { + status_code: StatusCode, + body: String, + }, + /// Cloudflare errors. CloudflareError(CloudflareError), @@ -66,6 +73,8 @@ impl Display for ApiError { ApiError::TooManyRequests(msg) => write!(f, "Too many requests to Storyteller backend: {}", msg), ApiError::InternalServerError {body, backend_hostname} => write!(f, "Internal Server Error; backend hostname: {:?} ; body: {}; ", backend_hostname, body), + ApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body } => + write!(f, "Uncategorized bad response with status code {} and body: {}", status_code, body), // Server response handling errors ApiError::DeserializationError(error) => write!(f, "Deserialization error: {}", error), // Network errors diff --git a/crates/api_clients/artcraft/artcraft_client/src/recipes/download_media_file.rs b/crates/api_clients/artcraft/artcraft_client/src/recipes/download_media_file.rs new file mode 100644 index 0000000000..a4eb3903b6 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/recipes/download_media_file.rs @@ -0,0 +1,157 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use log::{info, warn}; +use reqwest::Client; +use url::Url; + +use tokens::tokens::media_files::MediaFileToken; + +use crate::endpoints::media_files::get_media_file::{get_media_file, GetMediaFileSuccessResponse}; +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use crate::error::storyteller_error::StorytellerError; +use crate::utils::api_host::ApiHost; + +pub struct DownloadMediaFileArgs<'a, P: AsRef> { + pub media_token: &'a MediaFileToken, + pub api_host: &'a ApiHost, + pub download_path: DownloadPath

, +} + +pub enum DownloadPath> { + /// Write the file to this exact path. + ExactFilename(P), + /// Write the file into this directory, generating a filename from the CDN URL extension. + Directory(P), +} + +pub struct DownloadMediaFileResult { + /// The path the file was written to. + pub downloaded_file_path: PathBuf, + + /// The full media file response from the API. + pub media_file_response: GetMediaFileSuccessResponse, + + /// Size of downloaded file in bytes. + pub filesize_bytes: usize, +} + +pub async fn download_media_file>( + args: DownloadMediaFileArgs<'_, P>, +) -> Result { + let DownloadMediaFileArgs { + media_token, + api_host, + download_path + } = args; + + // 1. Fetch media file info from the API. + let response = get_media_file(api_host, media_token).await?; + let media_class = &response.media_file.media_class; + let cdn_url = &response.media_file.media_links.cdn_url; + + info!("Downloading media file {} of class {} from CDN: {}", + media_token.as_str(), media_class.to_str(), cdn_url); + + // 2. Determine the output file path. + let output_path = match &download_path { + DownloadPath::ExactFilename(path) => path.as_ref().to_path_buf(), + DownloadPath::Directory(dir) => { + let filename = derive_filename_from_url(cdn_url, &media_token); + dir.as_ref().join(filename) + } + }; + + // 3. Download the bytes from the CDN. + let bytes = download_bytes(cdn_url).await?; + + // 4. Write to disk. + let mut file = fs::File::create(&output_path) + .map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?; + + file.write_all(&bytes) + .map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?; + + file.flush() + .map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?; + + info!("Downloaded {} bytes to {:?}", bytes.len(), output_path); + + Ok(DownloadMediaFileResult { + downloaded_file_path: output_path, + media_file_response: response, + filesize_bytes: bytes.len(), + }) +} + +// ── Helpers ── + +async fn download_bytes(url: &Url) -> Result, StorytellerError> { + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?; + + let response = client.get(url.as_str()) + .send() + .await + .map_err(|err| StorytellerError::Api(ApiError::OtherReqwestError(err)))?; + + let status_code = response.status(); + + if !status_code.is_success() { + let body = response.text().await.unwrap_or_else(|err| { + warn!("Failed to retrieve response body: {}", err); + "".to_string() + }); + return Err(StorytellerError::Api(ApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body, + })); + } + + let bytes = response.bytes() + .await + .map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?; + + Ok(bytes.to_vec()) +} + +/// Derive a filename from the CDN URL's path extension, falling back to the media token. +fn derive_filename_from_url(url: &Url, media_token: &MediaFileToken) -> String { + let path = url.path(); + let extension = Path::new(path) + .extension() // NB: without dot '.' + .and_then(|ext| ext.to_str()) + .unwrap_or("bin"); + + format!("{}.{}", media_token.as_str(), extension) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_filename_from_url_extracts_extension() { + let url = Url::parse("https://cdn.example.com/files/abc123.png").unwrap(); + let token = MediaFileToken::new_from_str("mf_test123"); + assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.png"); + } + + #[test] + fn derive_filename_from_url_handles_no_extension() { + let url = Url::parse("https://cdn.example.com/files/abc123").unwrap(); + let token = MediaFileToken::new_from_str("mf_test123"); + assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.bin"); + } + + #[test] + fn derive_filename_from_url_handles_query_params() { + let url = Url::parse("https://cdn.example.com/files/abc123.mp4?token=xyz").unwrap(); + let token = MediaFileToken::new_from_str("mf_test123"); + assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.mp4"); + } +} diff --git a/crates/api_clients/artcraft/artcraft_client/src/recipes/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/recipes/mod.rs index 1232fbe14e..f007a12fa4 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/recipes/mod.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/recipes/mod.rs @@ -1 +1,2 @@ +pub mod download_media_file; pub mod upload_media_file_from_file; \ No newline at end of file