collective

Browse / github.com/storytold/artcraft-services / patch

Handle completed Kinovi audio orders and import Suno clips

Handle completed Kinovi audio orders and import Suno clips Completed Kinovi orders with `mediaType: "audio"` currently parse as `Unknown("audio")` and enter the video importer. That importer only accepts MP4/MOV, so an MP3/WAV result errors and the processing loop leaves the job pending for another retry. This adds audio classification and a dedicated importer, preserving the existing image/video and legacy missing-media-type paths. The importer validates the downloaded format, preserves multi-clip results as a batch, supports the top-level result URL fallback, and stores audio media records. Downloads/uploads happen before acquiring the database con

Status
open
Score
0.00
Adopters
0 other operators
Author
AndyParka (imported from pull request #167, open)
Base
e57ee3d63ac478a24149384bf1473ef97aa067f8
Size
+387 −8 in 5 files
Review
pending
Shared
2026-10-28
Id
245c2f701260f0a0510a7e43

How it went

No other operator has reported using it yet.

Diff

raw · crates/api_clients/kinovi_web_client/src/requests/poll_orders/poll_orders.rs crates/schema/database/mysql_queries/src/queries/media_files/create/insert_builder/media_file_insert_builder.rs crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/mod.rs crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_one_order.rs crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_audio_job.rs

diff --git a/crates/api_clients/kinovi_web_client/src/requests/poll_orders/poll_orders.rs b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/poll_orders.rs--- a/crates/api_clients/kinovi_web_client/src/requests/poll_orders/poll_orders.rs+++ b/crates/api_clients/kinovi_web_client/src/requests/poll_orders/poll_orders.rs@@ -36,7 +36,7 @@ pub struct PollOrdersResponse {  // --- Public types --- -/// The status of one order (one generation task — video or image).+/// The status of one order (one generation task — video, image, or audio). #[derive(Debug, Clone)] pub struct OrderStatus {   pub order_id: String,@@ -61,7 +61,7 @@ pub struct OrderStatus {   /// Parsed `created_at` as a `DateTime<Utc>`. `None` if the raw string could not be parsed.   pub created_at_utc: Option<DateTime<Utc>>, -  /// Whether this order produced an image or a video. `None` for older+  /// Whether this order produced an image, video, or audio. `None` for older   /// polling responses that didn't include the field — those came from the   /// video-only era and can be treated as video by callers that need to.   pub media_type: Option<OrderMediaType>,@@ -119,14 +119,15 @@ pub struct MediaResult { }  /// Media type of an order. Midjourney orders are `Image`; the various-/// Seedance/keyframe/reference flows are `Video`.+/// Seedance/keyframe/reference flows are `Video`; Suno orders are `Audio`. ///-/// `Unknown` covers response payloads that omit the field (older video-/// polling) or return an unrecognised value.+/// `Unknown` preserves unrecognised values. An omitted field is represented+/// by `None` on `OrderStatus`. #[derive(Debug, Clone, PartialEq)] pub enum OrderMediaType {   Image,   Video,+  Audio,   Unknown(String), } @@ -135,12 +136,14 @@ impl OrderMediaType {     match s {       "image" => Self::Image,       "video" => Self::Video,+      "audio" => Self::Audio,       other => Self::Unknown(other.to_string()),     }   }    pub fn is_image(&self) -> bool { matches!(self, Self::Image) }   pub fn is_video(&self) -> bool { matches!(self, Self::Video) }+  pub fn is_audio(&self) -> bool { matches!(self, Self::Audio) } }  // --- Implementation ---@@ -275,6 +278,14 @@ mod tests {   use errors::AnyhowResult;   use log::LevelFilter; +  #[test]+  fn recognizes_audio_orders_without_changing_legacy_types() {+    assert_eq!(OrderMediaType::from_str("audio"), OrderMediaType::Audio);+    assert_eq!(OrderMediaType::from_str("image"), OrderMediaType::Image);+    assert_eq!(OrderMediaType::from_str("video"), OrderMediaType::Video);+    assert_eq!(OrderMediaType::from_str("future"), OrderMediaType::Unknown("future".into()));+  }+   fn test_session() -> AnyhowResult<KinoviWebSession> {     let cookies = get_test_cookies()?;     Ok(KinoviWebSession::from_cookies_string(cookies))@@ -438,6 +449,28 @@ mod tests {       }).collect()     } +    /// Synthetic contract fixture: audio clips need neither video dimensions+    /// nor a top-level result URL when the results array is populated.+    #[test]+    fn audio_order_preserves_both_clips_without_dimensions() {+      let body = r#"[{"result":{"data":{"json":{"orders":[{+        "orderId":"ord_suno_audio",+        "resultUrl":null,+        "taskStatus":"COMPLETED",+        "results":[{"url":"https://example.test/one.mp3"},{"url":"https://example.test/two.mp3"}],+        "failReason":null,+        "createdAt":"2026-10-18T00:00:00Z",+        "mediaType":"audio"+      }],"nextCursor":null}}}}]"#;+      let orders = parse_orders(body);+      assert_eq!(orders.len(), 1);+      assert_eq!(orders[0].media_type, Some(OrderMediaType::Audio));+      assert_eq!(orders[0].task_status, TaskStatus::Completed);+      assert_eq!(orders[0].results.len(), 2);+      assert_eq!(orders[0].results[1].url, "https://example.test/two.mp3");+      assert!(orders[0].results.iter().all(|clip| clip.maybe_width.is_none() && clip.maybe_height.is_none()));+    }+     /// Back-compat: when the raw payload omits `mediaType` (older video-only     /// polling), `media_type` parses as `None` and the rest of the order     /// continues to deserialise unchanged.@@ -555,12 +588,14 @@ mod tests {     println!("Next cursor: {:?}", result.next_cursor);     let mut images = 0;     let mut videos = 0;+    let mut audio = 0;     let mut unknown = 0;     let mut missing = 0;     for order in &result.orders {       match &order.media_type {         Some(OrderMediaType::Image) => images += 1,         Some(OrderMediaType::Video) => videos += 1,+        Some(OrderMediaType::Audio) => audio += 1,         Some(OrderMediaType::Unknown(_)) => unknown += 1,         None => missing += 1,       }@@ -569,8 +604,8 @@ mod tests {         order.results.len(), order.result_url, order.fail_reason);     }     println!(-      "media_type tally: image={}, video={}, unknown={}, missing={}",-      images, videos, unknown, missing,+      "media_type tally: image={}, video={}, audio={}, unknown={}, missing={}",+      images, videos, audio, unknown, missing,     );     assert_eq!(1, 2); // NB: Intentional failure to inspect output.     Ok(())diff --git a/crates/schema/database/mysql_queries/src/queries/media_files/create/insert_builder/media_file_insert_builder.rs b/crates/schema/database/mysql_queries/src/queries/media_files/create/insert_builder/media_file_insert_builder.rs--- a/crates/schema/database/mysql_queries/src/queries/media_files/create/insert_builder/media_file_insert_builder.rs+++ b/crates/schema/database/mysql_queries/src/queries/media_files/create/insert_builder/media_file_insert_builder.rs@@ -304,7 +304,10 @@ impl MediaFileInsertBuilder {     self.insert_acquire(mysql_connection).await   } -  async fn insert_acquire<'a, A>(&'a self, connector: A) -> Result<MediaFileToken, MediaFileInsertBuilderError>+  /// Insert through a pool, connection, or existing transaction. Within a+  /// transaction the underlying helper uses a savepoint; the caller controls+  /// the final commit, including any associated job-status update.+  pub async fn insert_acquire<'a, A>(&'a self, connector: A) -> Result<MediaFileToken, MediaFileInsertBuilderError>   where     A: Acquire<'a, Database = MySql> + Send,     <A as Acquire<'a>>::Connection: Send,diff --git a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/mod.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/mod.rs--- a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/mod.rs+++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/mod.rs@@ -4,3 +4,4 @@ pub mod process_one_order; pub mod process_failed_job; pub mod process_successful_image_job; pub mod process_successful_video_job;+pub mod process_successful_audio_job;diff --git a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_one_order.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_one_order.rs--- a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_one_order.rs+++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_one_order.rs@@ -4,6 +4,7 @@ use errors::AnyhowResult; use kinovi_web_client::requests::poll_orders::poll_orders::{OrderMediaType, TaskStatus};  use crate::job_dependencies::JobDependencies;+use crate::jobs::order_processing_job::process_successful_audio_job::process_successful_audio_job; use crate::jobs::order_processing_job::process_failed_job::process_failed_job; use crate::jobs::order_processing_job::process_successful_image_job::process_successful_image_job; use crate::jobs::order_processing_job::process_successful_video_job::process_successful_video_job;@@ -25,6 +26,7 @@ pub async fn process_one_order(       // response shapes that pre-date the `mediaType` field) and `Some(Video)`       // both fall to the video handler for back-compat.       match &order.media_type {+        Some(OrderMediaType::Audio) => process_successful_audio_job(deps, job, order).await,         Some(OrderMediaType::Image) => process_successful_image_job(deps, job, order).await,         Some(OrderMediaType::Video) | None => process_successful_video_job(deps, job, order).await,         Some(OrderMediaType::Unknown(other)) => {diff --git a/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_audio_job.rs b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_audio_job.rsnew file mode 100644--- /dev/null+++ b/crates/service/job/seedance2_pro_job/src/jobs/order_processing_job/process_successful_audio_job.rs@@ -0,0 +1,338 @@+use anyhow::anyhow;+use log::{error, info, warn};++use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;+use enums::by_table::generic_inference_jobs::inference_result_type::InferenceResultType;+use enums::by_table::media_files::media_file_class::MediaFileClass;+use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;+use enums::by_table::media_files::media_file_type::MediaFileType;+use enums::common::generation_provider::GenerationProvider;+use errors::AnyhowResult;+use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;+use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;+use mysql_queries::queries::generic_inference::api_providers::kinovi_web::list_pending_kinovi_web_video_jobs::PendingKinoviWebJob;+use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update;+use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token_with_executor::{mark_generic_inference_job_successfully_done_by_token_with_executor, MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs};+use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;+use kinovi_web_client::requests::poll_orders::poll_orders::{OrderStatus, MediaResult};+use tokens::tokens::batch_generations::BatchGenerationToken;++use crate::alert_on_error::alert_pager_and_return_err;+use crate::job_dependencies::JobDependencies;+use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal;++const PREFIX: &str = "artcraft_";++/// Import a completed Suno audio order from Kinovi.+///+/// Preserve every returned clip in one batch and expose the first as the+/// primary result. Download and validate audio before marking the job complete.+pub async fn process_successful_audio_job(+  deps: &JobDependencies,+  job: &PendingKinoviWebJob,+  order: &OrderStatus,+) -> AnyhowResult<()> {+  let results = audio_results(order)?;++  info!(+    "Processing completed audio order {}: {} audio(s) to download.",+    order.order_id,+    results.len(),+  );++  // Complete all network IO before acquiring a database connection. Media+  // records and job completion are then committed together so a failed second+  // clip or a retry cannot expose partial/duplicate batches in the library.+  let maybe_batch_token = (results.len() > 1).then(BatchGenerationToken::generate);+  let mut media_files = Vec::with_capacity(results.len());+  for (idx, result) in results.iter().enumerate() {+    media_files.push(+      download_and_upload_audio(deps, job, order, result, idx, maybe_batch_token.as_ref()).await?,+    );+  }++  // Finalize inside a transaction: re-check the job is still pending under a row+  // lock, then mark it complete. See `process_successful_video_job` for the rationale.+  let mut transaction = deps.mysql_pool.begin().await.map_err(|err| {+    anyhow!(+      "error beginning finalize transaction for job {}: {:?}",+      job.job_token.as_str(),+      err+    )+  })?;++  let maybe_status = select_inference_job_status_for_update(&mut *transaction, &job.job_token)+    .await+    .map_err(|err| {+      anyhow!(+        "error locking job {} for finalize: {:?}",+        job.job_token.as_str(),+        err+      )+    })?;++  // A concurrent completion or cancellation must win over this import.+  let status = match maybe_status {+    Some(status) => status,+    None => {+      let _ = transaction.rollback().await;+      return Err(anyhow!(+        "Job {} vanished before finalize (order {})",+        job.job_token.as_str(),+        order.order_id,+      ));+    },+  };++  if is_job_status_terminal(status) {+    warn!(+      "Job {} is already terminal ({:?}); skipping mark-done (order {}). \+      {} bucket object(s) may be orphaned.",+      job.job_token.as_str(),+      status,+      order.order_id,+      media_files.len(),+    );+    let _ = transaction.rollback().await;+    return Ok(());+  }++  let mut primary_token = None;+  for media_file in &media_files {+    let token = media_file.insert_acquire(&mut *transaction).await?;+    if primary_token.is_none() {+      primary_token = Some(token);+    }+  }+  let primary_token = primary_token.ok_or_else(|| anyhow!("No audio results to finalize"))?;++  // Commit all clips and the job status within the same transaction.++  if let Err(err) = mark_generic_inference_job_successfully_done_by_token_with_executor(+    MarkGenericInferenceJobSuccessfullyDoneByTokenWithExecutorArgs {+      executor: &mut *transaction,+      token: &job.job_token,+      maybe_entity_type: Some(InferenceResultType::MediaFile),+      maybe_entity_token: Some(primary_token.as_str()),+      total_job_duration: None,+      inference_duration: None,+    },+  )+  .await+  {+    let _ = transaction.rollback().await;+    error!(+      "Error marking audio job {} done: {:?}",+      job.job_token.as_str(),+      err+    );+    return alert_pager_and_return_err(+      &deps.pager,+      "Kinovi audio job completion update failed",+      anyhow!("error marking job done: {:?}", err),+      Some(job),+    );+  }++  transaction.commit().await.map_err(|err| {+    anyhow!(+      "error committing finalize transaction for job {}: {:?}",+      job.job_token.as_str(),+      err+    )+  })?;++  info!(+    "Audio job {} completed successfully.",+    job.job_token.as_str()+  );++  Ok(())+}++/// Upload a clip and prepare its media record without holding a DB connection.+async fn download_and_upload_audio(+  deps: &JobDependencies,+  job: &PendingKinoviWebJob,+  order: &OrderStatus,+  result: &MediaResult,+  index: usize,+  maybe_batch_token: Option<&BatchGenerationToken>,+) -> AnyhowResult<MediaFileInsertBuilder> {+  let audio_url = result.url.as_str();++  info!(+    "Downloading audio {}/{} for order {} from: {}",+    index + 1,+    order.results.len().max(1),+    order.order_id,+    audio_url,+  );++  let audio_bytes: Vec<u8> = deps+    .download_client+    .get(audio_url)+    .send()+    .await+    .map_err(|err| anyhow!("reqwest error downloading audio {}: {:?}", index, err))?+    .error_for_status()+    .map_err(|err| anyhow!("HTTP error downloading audio {}: {:?}", index, err))?+    .bytes()+    .await+    .map_err(|err| anyhow!("error reading audio bytes for index {}: {:?}", index, err))?+    .to_vec();++  info!(+    "Downloaded {} bytes for audio {}/{} of order {}",+    audio_bytes.len(),+    index + 1,+    order.results.len().max(1),+    order.order_id,+  );++  let (suffix, mime_type, file_type) = audio_metadata(&audio_bytes)?;++  let checksum = sha256_hash_bytes(&audio_bytes)+    .map_err(|err| anyhow!("error hashing audio {}: {:?}", index, err))?;++  let bucket_path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(suffix));+  let object_path = bucket_path.get_full_object_path_str();++  info!(+    "Uploading audio {}/{} to public bucket at path: {}",+    index + 1,+    order.results.len().max(1),+    object_path,+  );++  deps+    .public_bucket_client+    .upload_file_with_content_type_process(object_path, &audio_bytes, mime_type)+    .await+    .map_err(|err| anyhow!("bucket upload failed for audio {}: {:?}", index, err))?;++  let media_file = MediaFileInsertBuilder::new()+    .checksum_sha2(&checksum)+    .creator_ip_address(&job.creator_ip_address)+    .creator_set_visibility(job.creator_set_visibility)+    .file_size_bytes(audio_bytes.len() as u64)+    .maybe_batch_generation_token(maybe_batch_token)+    .maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())+    .maybe_creator_user(job.maybe_creator_user_token.as_ref())+    .maybe_generation_provider(Some(GenerationProvider::Artcraft))+    .maybe_prompt_token(job.maybe_prompt_token.as_ref())+    .maybe_source_job_token(Some(&job.job_token))+    .maybe_platform_type(job.maybe_platform_type)+    .media_file_class(MediaFileClass::Audio)+    .media_file_origin_category(MediaFileOriginCategory::Inference)+    .media_file_type(file_type)+    .mime_type(mime_type)+    .public_bucket_directory_hash(&bucket_path);++  Ok(media_file)+}++/// Some provider responses expose only a top-level URL. Do not lose those+/// single-clip orders or import a duplicate of the primary clip in a batch.+fn audio_results(order: &OrderStatus) -> AnyhowResult<Vec<MediaResult>> {+  if !order.results.is_empty() {+    return Ok(order.results.clone());+  }+  let url = order+    .result_url+    .as_ref()+    .filter(|url| !url.is_empty())+    .ok_or_else(|| anyhow!("Completed audio order {} has no results", order.order_id))?;+  Ok(vec![MediaResult {+    url: url.clone(),+    maybe_width: None,+    maybe_height: None,+  }])+}++fn audio_metadata(bytes: &[u8]) -> AnyhowResult<(&'static str, &'static str, MediaFileType)> {+  match get_mimetype_for_bytes(bytes) {+    Some("audio/mpeg") => Ok((".mp3", "audio/mpeg", MediaFileType::Mp3)),+    Some("audio/x-wav" | "audio/wav") => Ok((".wav", "audio/wav", MediaFileType::Wav)),+    Some("audio/flac" | "audio/x-flac") => Ok((".flac", "audio/flac", MediaFileType::Flac)),+    Some("audio/ogg") => Ok((".ogg", "audio/ogg", MediaFileType::Ogg)),+    Some("audio/x-m4a" | "audio/m4a") => Ok((".m4a", "audio/mp4", MediaFileType::M4a)),+    Some("audio/aac" | "audio/x-aac") => Ok((".aac", "audio/aac", MediaFileType::Aac)),+    other => Err(anyhow!(+      "Unsupported Kinovi audio result MIME type: {:?}",+      other+    )),+  }+}++#[cfg(test)]+mod tests {+  use super::*;+  use kinovi_web_client::requests::poll_orders::poll_orders::{OrderMediaType, TaskStatus};++  #[test]+  fn accepts_mp3_and_wav_with_correct_file_metadata() {+    assert_eq!(+      audio_metadata(b"ID3\x04\x00\x00\x00\x00\x00\x00").unwrap(),+      (".mp3", "audio/mpeg", MediaFileType::Mp3)+    );+    assert_eq!(+      audio_metadata(b"RIFF\x24\x00\x00\x00WAVEfmt ").unwrap(),+      (".wav", "audio/wav", MediaFileType::Wav)+    );+  }++  #[test]+  fn rejects_video_and_error_pages_instead_of_importing_them_as_audio() {+    assert!(audio_metadata(b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00isomiso2avc1mp41").is_err());+    assert!(audio_metadata(b"<html>Access denied</html>").is_err());+  }++  #[test]+  fn preserves_both_clips_without_duplicating_the_primary_url() {+    let mut order = completed_order();+    order.result_url = Some("https://example.test/one.mp3".into());+    order.results = vec![+      clip("https://example.test/one.mp3"),+      clip("https://example.test/two.mp3"),+    ];+    let results = audio_results(&order).unwrap();+    assert_eq!(results.len(), 2);+    assert_eq!(results[0].url, "https://example.test/one.mp3");+    assert_eq!(results[1].url, "https://example.test/two.mp3");+  }++  #[test]+  fn supports_top_level_only_results_and_rejects_missing_results() {+    let mut order = completed_order();+    assert!(audio_results(&order).is_err());+    order.result_url = Some("https://example.test/song.mp3".into());+    let results = audio_results(&order).unwrap();+    assert_eq!(results.len(), 1);+    assert_eq!(results[0].url, "https://example.test/song.mp3");+    order.result_url = Some(String::new());+    assert!(audio_results(&order).is_err());+  }++  fn clip(url: &str) -> MediaResult {+    MediaResult {+      url: url.into(),+      maybe_width: None,+      maybe_height: None,+    }+  }++  fn completed_order() -> OrderStatus {+    OrderStatus {+      order_id: "audio-order".into(),+      task_status: TaskStatus::Completed,+      result_url: None,+      results: vec![],+      fail_reason: None,+      created_at: "2026-10-18T00:00:00Z".into(),+      created_at_utc: None,+      media_type: Some(OrderMediaType::Audio),+      total_credits: None,+    }+  }+}