Compare commits

..
Author SHA1 Message Date
Brandon Thomas 0cc7a9b434 fallback + error handling in fal handlers 2026-04-12 05:20:24 -04:00
Brandon Thomas 6b5f296048 backup in case mime type detection fails 2026-04-12 04:13:37 -04:00
Brandon Thomas 9dc237f48a test stubs 2026-04-12 03:47:46 -04:00
Brandon Thomas 2e1ce43cc0 refactor 2026-04-12 03:29:56 -04:00
Brandon Thomas 695d6b11a5 new failure test data + test 2026-04-12 03:27:34 -04:00
45 changed files with 556 additions and 908 deletions
Generated
-9
View File
@@ -3876,13 +3876,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "error_utils"
version = "0.0.1"
dependencies = [
"regex",
]
[[package]]
name = "errors"
version = "0.0.1"
@@ -10179,7 +10172,6 @@ dependencies = [
"anyhow",
"bootstrap",
"bucket_paths",
"chrono",
"cloud_storage",
"concurrency",
"easyenv",
@@ -11241,7 +11233,6 @@ dependencies = [
"elasticsearch",
"elasticsearch_schema",
"enums",
"error_utils",
"errors",
"fal_client",
"ffmpeg_utils",
-2
View File
@@ -59,7 +59,6 @@ members = [
"crates/lib/data/primitives",
"crates/lib/data/web_base64",
"crates/lib/easyenv", # NB: Vendored version of https://github.com/echelon/easyenv.rs that we'll improve.
"crates/lib/error_utils",
"crates/lib/errors",
"crates/lib/ffmpeg_utils",
"crates/lib/files/file_to_base64_url",
@@ -237,7 +236,6 @@ cookie_store = { path = "crates/lib/cookie_store" }
crockford = { path = "crates/lib/data/crockford" }
easyenv = { path = "crates/lib/easyenv" }
enums = { path = "crates/schema/public/enums" }
error_utils = { path = "crates/lib/error_utils" }
errors = { path = "crates/lib/errors" }
fal_client = { path = "crates/api_clients/fal_client" }
ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
@@ -1,142 +0,0 @@
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::get_user_auth_details::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use wreq::Client;
use wreq_util::Emulation;
// --- Args & response ---
pub struct GetUserAuthDetailsArgs<'a> {
pub session: &'a Seedance2ProSession,
/// Override the default host (kinovi.ai).
pub host_override: Option<KinoviHost>,
}
#[derive(Debug)]
pub struct GetUserAuthDetailsResponse {
pub email: String,
pub credits: u64,
pub available_credits: u64,
}
// --- Implementation ---
/// The tRPC `input` query param for `auth.user` — a static null payload.
const AUTH_USER_INPUT: &str =
r#"{"0":{"json":null,"meta":{"values":["undefined"],"v":1}}}"#;
pub async fn get_user_auth_details(
args: GetUserAuthDetailsArgs<'_>,
) -> Result<GetUserAuthDetailsResponse, Seedance2ProError> {
let host = resolve_host(args.host_override.as_ref());
let base_url = host.api_base_url();
let url = format!("{}/api/trpc/auth.user", base_url);
info!("Fetching user auth details...");
let client = Client::builder()
.emulation(Emulation::Firefox143)
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let cookie = args.session.cookies.as_str();
let referer = format!("{}/pricing", base_url);
let request = client.get(&url)
.query(&[("batch", "1"), ("input", AUTH_USER_INPUT)])
.header("User-Agent", FIREFOX_USER_AGENT)
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Accept-Encoding", "gzip, deflate, br, zstd")
.header("Referer", &referer)
.header("content-type", "application/json")
.header("x-trpc-source", "client")
.header("Connection", "keep-alive")
.header("Cookie", cookie)
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "same-origin")
.header("Priority", "u=4")
.header("TE", "trailers")
.build()
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
let response = client.execute(request)
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
info!("Get user auth details response status: {}", status);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
.map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(
err,
response_body.clone(),
))?;
let json = batch_response
.into_iter()
.next()
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
"Empty batch response array".to_string()
))?
.result
.data
.json;
info!("User: {}, credits: {}, available: {}", json.email, json.credits, json.available_credits);
Ok(GetUserAuthDetailsResponse {
email: json.email,
credits: json.credits,
available_credits: json.available_credits,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::test_utils::get_test_cookies::get_test_cookies;
use crate::test_utils::setup_test_logging::setup_test_logging;
use errors::AnyhowResult;
use log::LevelFilter;
fn test_session() -> AnyhowResult<Seedance2ProSession> {
let cookies = get_test_cookies()?;
Ok(Seedance2ProSession::from_cookies_string(cookies))
}
#[tokio::test]
#[ignore] // manually test — requires real cookies
async fn test_get_user_auth_details() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Trace);
let session = test_session()?;
let result = get_user_auth_details(GetUserAuthDetailsArgs {
session: &session,
host_override: None,
}).await?;
println!("Email: {}", result.email);
println!("Credits: {}", result.credits);
println!("Available credits: {}", result.available_credits);
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
Ok(())
}
}
@@ -1,2 +0,0 @@
pub mod get_user_auth_details;
mod request_types;
@@ -1,24 +0,0 @@
use serde_derive::Deserialize;
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseItem {
pub result: BatchResponseResult,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseResult {
pub data: BatchResponseData,
}
#[derive(Deserialize, Debug)]
pub(super) struct BatchResponseData {
pub json: RawUserAuthDetails,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawUserAuthDetails {
pub email: String,
pub credits: u64,
pub available_credits: u64,
}
@@ -1,6 +1,5 @@
pub mod generate_character;
pub mod generate_video;
pub mod get_user_auth_details;
pub mod kinovi_host;
pub mod poll_characters;
pub mod poll_orders;
@@ -6,7 +6,6 @@ use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::requests::poll_orders::failure_reason::FailureReason;
use crate::requests::poll_orders::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use chrono::{DateTime, Utc};
use log::info;
use wreq::Client;
use wreq_util::Emulation;
@@ -95,9 +94,6 @@ pub struct OrderStatus {
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
pub created_at: String,
/// Parsed `created_at` as a `DateTime<Utc>`. `None` if the raw string could not be parsed.
pub created_at_utc: Option<DateTime<Utc>>,
}
// --- Implementation ---
@@ -180,10 +176,6 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
(None, TaskStatus::Failed) => Some(FailureReason::from_reason("(no reason)")),
_ => None,
};
let created_at_utc = DateTime::parse_from_rfc3339(&o.created_at)
.map(|dt| dt.with_timezone(&Utc))
.ok();
OrderStatus {
order_id: o.order_id,
task_status,
@@ -195,7 +187,6 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
}).collect(),
fail_reason,
created_at: o.created_at,
created_at_utc,
}
})
.collect();
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "error_utils"
edition = "2021"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[lib]
name = "error_utils"
path = "src/lib.rs"
[dependencies]
regex.workspace = true
-1
View File
@@ -1 +0,0 @@
pub mod try_error_name;
@@ -1,157 +0,0 @@
use regex::Regex;
use std::sync::LazyLock;
/// Regex that matches CamelCase type names with an optional `::Variant` suffix.
/// E.g. `CommonWebError::InvalidRequest`, `EnvError`, `KinoviError::RequestError`.
static ERROR_NAME_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*(?:::[A-Z][0-9a-z]+(?:[A-Z][0-9a-z]+)*)?")
.expect("error name regex should compile")
});
/// Try to extract a human-readable error title from the causal error's Debug
/// representation. Looks for CamelCase type names like `EnvError::NotUnicode`.
pub fn try_error_name(error: &dyn std::error::Error) -> Option<String> {
let debug_str = format!("{:?}", error);
try_extract_error_name(&debug_str)
}
/// Extract the leading CamelCase error name (with optional `::Variant`) from
/// a Debug-formatted error string. Returns `None` if no match is found.
///
/// ```text
/// "CommonWebError::InvalidRequest(\"bad\")" → Some("CommonWebError::InvalidRequest")
/// "EnvError::ParseError { reason: \"foo\" }" → Some("EnvError::ParseError")
/// "just a plain string" → None
/// ```
pub (crate) fn try_extract_error_name(debug_str: &str) -> Option<String> {
ERROR_NAME_RE.find(debug_str)
.map(|m| m.as_str().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
// --- try_extract_error_name tests ---
#[test]
fn extract_just_error_name() {
assert_eq!(
try_extract_error_name("JustErrorName"),
Some("JustErrorName".to_string()),
);
}
#[test]
fn extract_error_with_variant_and_string_payload() {
assert_eq!(
try_extract_error_name(r#"CommonWebError::InvalidRequest("Invalid request")"#),
Some("CommonWebError::InvalidRequest".to_string()),
);
}
#[test]
fn extract_error_with_unit_variant() {
assert_eq!(
try_extract_error_name("EnvError::NotUnicode"),
Some("EnvError::NotUnicode".to_string()),
);
}
#[test]
fn extract_error_with_struct_payload() {
assert_eq!(
try_extract_error_name(r#"EnvError::ParseError { reason: "foo" }"#),
Some("EnvError::ParseError".to_string()),
);
}
#[test]
fn extract_error_with_tuple_payload() {
assert_eq!(
try_extract_error_name("KinoviError::RequestError(asdf)"),
Some("KinoviError::RequestError".to_string()),
);
}
#[test]
fn extract_error_with_nested_errors() {
assert_eq!(
try_extract_error_name(
"ComplexError::SomeError(SomeError::SomeVariant(SomeInnerError::SomeInnerVariant))"
),
Some("ComplexError::SomeError".to_string()),
);
}
#[test]
fn extract_error_with_struct_containing_nested() {
assert_eq!(
try_extract_error_name(
"WrappedError::Variant { inner: SomeInnerError::SomeInnerVariant }"
),
Some("WrappedError::Variant".to_string()),
);
}
#[test]
fn no_match_for_lowercase_string() {
assert_eq!(
try_extract_error_name("just a plain error message with no type name"),
None,
);
}
#[test]
fn no_match_for_empty_string() {
assert_eq!(try_extract_error_name(""), None);
}
#[test]
fn single_word_camel_case() {
assert_eq!(
try_extract_error_name("Error"),
Some("Error".to_string()),
);
}
// --- try_error_title tests (with real error types) ---
#[test]
fn title_from_io_error() {
let err: Box<dyn std::error::Error> = Box::new(
std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
);
let title = try_error_name(err.as_ref());
assert!(title.is_some());
}
/// A simple custom error for testing.
#[derive(Debug)]
enum TestAppError {
DatabaseTimeout,
InvalidInput(String),
}
impl std::fmt::Display for TestAppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl std::error::Error for TestAppError {}
#[test]
fn title_from_custom_unit_variant() {
let err = TestAppError::DatabaseTimeout;
let title = try_error_name(&err);
assert_eq!(title, Some("DatabaseTimeout".to_string()));
}
#[test]
fn title_from_custom_tuple_variant() {
let err = TestAppError::InvalidInput("bad data".to_string());
let title = try_error_name(&err);
assert_eq!(title, Some("InvalidInput".to_string()));
}
}
@@ -15,7 +15,7 @@ use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
pub struct PendingSeedance2ProJob {
pub job_token: InferenceJobToken,
/// Database column `maybe_external_third_party_id` is Kinovi's `order_id`.
/// The external order_id from seedance2-pro.com.
pub order_id: String,
pub maybe_creator_user_token: Option<UserToken>,
@@ -41,7 +41,6 @@ tokens.workspace = true
# External
anyhow.workspace = true
chrono.workspace = true
log.workspace = true
sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ] }
@@ -2,10 +2,3 @@
# Development
SERVER_ENVIRONMENT='development'
# Paging
ENABLE_PAGING=true
# Doesn't make sense for jobs:
# ENABLE_PAGING_FOR_500S=true
@@ -1,6 +1,3 @@
use std::sync::Arc;
use chrono::Duration;
use cloud_storage::bucket_client::BucketClient;
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
use jobs_common::job_stats::JobStats;
@@ -8,7 +5,6 @@ use pager::client::pager::Pager;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use server_environment::ServerEnvironment;
use sqlx::MySqlPool;
use tokio::sync::Notify;
#[derive(Clone)]
pub struct JobDependencies {
@@ -32,21 +28,9 @@ pub struct JobDependencies {
/// when the order list is very long.
pub maybe_pages_per_batch: Option<u32>,
/// If set, stop paginating backwards through orders once we encounter
/// an order older than this duration. This prevents endlessly scanning
/// ancient orders that will never match a pending job.
pub maybe_max_job_age: Option<Duration>,
/// Page an alert when available Kinovi credits fall below this threshold.
pub credits_alert_threshold: u64,
/// Set to `true` from another thread to trigger graceful shutdown.
pub application_shutdown: RelaxedAtomicBool,
/// Notified when `application_shutdown` is set. Allows sleeping tasks
/// to wake up immediately instead of waiting for the full sleep duration.
pub shutdown_notify: Arc<Notify>,
/// Pager client for sending alerts.
pub pager: Pager,
}
@@ -29,10 +29,7 @@ pub async fn character_polling_main_loop(deps: JobDependencies) {
warn!("Character poll iteration took {:.1}s (threshold: {}s)", elapsed.as_secs_f64(), POLL_ALERT_THRESHOLD.as_secs());
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(deps.poll_interval_millis)) => {}
_ = deps.shutdown_notify.notified() => {}
}
tokio::time::sleep(Duration::from_millis(deps.poll_interval_millis)).await;
}
warn!("Character polling main loop is shut down.");
@@ -1,93 +0,0 @@
use std::cmp::min;
use std::time::Duration;
use log::{error, info, warn};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use seedance2pro_client::requests::get_user_auth_details::get_user_auth_details::{
get_user_auth_details, GetUserAuthDetailsArgs,
};
use crate::job_dependencies::JobDependencies;
const CREDITS_CHECK_INTERVAL: Duration = Duration::from_secs(30);
pub async fn credits_checking_main_loop(deps: JobDependencies) {
let mut consecutive_failures: u32 = 0;
while !deps.application_shutdown.get() {
match check_credits(&deps).await {
Ok(()) => {
consecutive_failures = 0;
}
Err(err) => {
consecutive_failures += 1;
error!("Credits check failed ({} consecutive): {:?}", consecutive_failures, err);
if consecutive_failures >= 2 {
let notification = NotificationDetailsBuilder::from_title(
"Kinovi credits check failing".to_string())
.set_description(Some(format!(
"Credits check has failed {} times in a row. Last error: {:?}",
consecutive_failures, err,
)))
.set_urgency(Some(NotificationUrgency::Medium))
.build();
if let Err(pager_err) = deps.pager.enqueue_page(notification) {
error!("Failed to enqueue credits check failure alert: {:?}", pager_err);
}
}
}
}
tokio::select! {
_ = tokio::time::sleep(CREDITS_CHECK_INTERVAL) => {}
_ = deps.shutdown_notify.notified() => {}
}
}
warn!("Credits checking main loop is shut down.");
}
async fn check_credits(deps: &JobDependencies) -> anyhow::Result<()> {
let response = get_user_auth_details(GetUserAuthDetailsArgs {
session: &deps.seedance2pro_session,
host_override: None,
})
.await
.map_err(|err| anyhow::anyhow!("get_user_auth_details failed: {:?}", err))?;
let credits = min(response.credits, response.available_credits);
let threshold = deps.credits_alert_threshold;
info!(
"Kinovi credits: {} (API credits: {}, API credits available: {}) (alert threshold: {})",
credits,
response.credits,
response.available_credits,
threshold,
);
if credits < threshold {
warn!(
"Kinovi credits are low! {} available credits (alert threshold: {})",
credits, threshold,
);
let notification = NotificationDetailsBuilder::from_title(
"Kinovi billing credits low !".to_string())
.set_description(Some(format!(
"Available credits: {}. Alert threshold: {}.",
credits, threshold,
)))
.set_urgency(Some(NotificationUrgency::High))
.build();
if let Err(pager_err) = deps.pager.enqueue_page(notification) {
error!("Failed to enqueue low credits alert: {:?}", pager_err);
}
}
Ok(())
}
@@ -1 +0,0 @@
pub mod credits_checking_main_loop;
@@ -1,3 +1,2 @@
pub mod character_polling_job;
pub mod credits_checking_job;
pub mod video_polling_job;
@@ -1,4 +1,4 @@
pub mod alert_on_error;
pub mod video_polling_main_loop;
pub mod process_job;
pub mod process_orders_batch;
pub mod process_page_batch;
@@ -12,22 +12,17 @@ use crate::jobs::video_polling_job::process_job::process_successful_job::process
///
/// Removes matched order_ids from `job_by_order_id` so they are not
/// processed again in subsequent batches.
pub async fn process_orders_batch(
pub async fn process_page_batch(
deps: &JobDependencies,
kinovi_orders: &[OrderStatus],
orders: &[OrderStatus],
job_by_order_id: &mut HashMap<String, PendingSeedance2ProJob>,
pages_in_current_batch: u32,
) {
log_batch_summary(&kinovi_orders, pages_in_current_batch);
let pending_job_count = job_by_order_id.len();
let mut batch_succeeded = 0u32;
let mut batch_failed = 0u32;
let mut batch_in_progress = 0u32;
let mut batch_matched = 0u32;
for order in kinovi_orders {
for order in orders {
let job = match job_by_order_id.remove(&order.order_id) {
Some(j) => j,
None => continue, // Not one of our pending jobs.
@@ -69,14 +64,8 @@ pub async fn process_orders_batch(
}
info!(
"Batch processing done: \
{} kinovi orders in polled batch, \
{} pending db jobs in batch at start, \
{} kinovi orders in batch matched pending jobs \
(orders succeeded={}, orders failed={}, orders in_progress={}), \
{} pending db jobs in batch remaining",
kinovi_orders.len(),
pending_job_count,
"Batch processing done: {} orders in batch, {} matched pending jobs (succeeded={}, failed={}, in_progress={}), {} pending jobs remaining",
orders.len(),
batch_matched,
batch_succeeded,
batch_failed,
@@ -84,24 +73,3 @@ pub async fn process_orders_batch(
job_by_order_id.len(),
);
}
fn log_batch_summary(orders: &[OrderStatus], pages_in_batch: u32) {
let mut succeeded = 0u32;
let mut failed = 0u32;
let mut in_progress = 0u32;
let mut unknown = 0u32;
for order in orders {
match &order.task_status {
TaskStatus::Completed => succeeded += 1,
TaskStatus::Failed => failed += 1,
TaskStatus::Pending | TaskStatus::Processing => in_progress += 1,
TaskStatus::Unknown(_) => unknown += 1,
}
}
info!(
"Processing batch of {} Kinovi order pages, {} total orders (succeeded={}, failed={}, in_progress={}, unknown={})",
pages_in_batch, orders.len(), succeeded, failed, in_progress, unknown
);
}
@@ -1,34 +1,27 @@
use std::collections::HashMap;
use std::time::{Duration, Instant};
use chrono::Utc;
use log::{error, info, warn};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::{list_pending_seedance2pro_video_jobs, PendingSeedance2ProJob};
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_video_jobs::list_pending_seedance2pro_video_jobs;
use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, TaskStatus};
use crate::jobs::video_polling_job::alert_on_error::alert_pager_and_return_err;
use crate::jobs::video_polling_job::process_orders_batch::process_orders_batch;
use crate::jobs::video_polling_job::process_page_batch::process_page_batch;
use crate::job_dependencies::JobDependencies;
const POLL_ALERT_THRESHOLD: Duration = Duration::from_mins(6);
const POLL_ALERT_THRESHOLD: Duration = Duration::from_secs(600);
pub async fn video_polling_main_loop(job_dependencies: JobDependencies) {
while !job_dependencies.application_shutdown.get() {
let start = Instant::now();
//
// Run a single polling iteration and alert if it takes too long
//
let result = run_poll_iteration(&job_dependencies).await;
let elapsed = start.elapsed();
if let Err(err) = result {
error!("Error in poll iteration: {:?}", err);
let _ = alert_pager_and_return_err::<()>(&job_dependencies.pager, "Kinovi poll iteration error", err, None);
let _ = alert_pager_and_return_err::<()>(&job_dependencies.pager, "Seedance2Pro poll iteration error", err, None);
let _ = job_dependencies.job_stats.increment_failure_count();
}
@@ -36,7 +29,7 @@ pub async fn video_polling_main_loop(job_dependencies: JobDependencies) {
warn!("Poll iteration took {:.1}s (threshold: {}s)", elapsed.as_secs_f64(), POLL_ALERT_THRESHOLD.as_secs());
let notification = NotificationDetailsBuilder::from_title(
"Kinovi poll iteration slow".to_string())
"Seedance2Pro poll iteration slow".to_string())
.set_description(Some(format!(
"Poll iteration took {:.1} seconds, exceeding the 10-minute threshold.",
elapsed.as_secs_f64(),
@@ -49,70 +42,39 @@ pub async fn video_polling_main_loop(job_dependencies: JobDependencies) {
}
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(job_dependencies.poll_interval_millis)) => {}
_ = job_dependencies.shutdown_notify.notified() => {}
}
tokio::time::sleep(Duration::from_millis(job_dependencies.poll_interval_millis)).await;
}
warn!("Kinovi job runner main loop is shut down.");
warn!("Seedance2Pro job runner main loop is shut down.");
}
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
// 1. Query all (limit 25,000) non-terminal Seedance2Pro jobs from DB.
// This is all non-(complete_success, complete_failure) jobs.
// 1. Query all non-terminal Seedance2Pro jobs from DB.
let pending_jobs = match list_pending_seedance2pro_video_jobs(&deps.mysql_pool).await {
Ok(jobs) => jobs,
Err(err) => {
error!("Failed to list pending database jobs: {:?}", err);
return alert_pager_and_return_err(&deps.pager, "Jobs DB query failed", err.into(), None);
error!("Failed to list pending seedance2pro jobs: {:?}", err);
return alert_pager_and_return_err(&deps.pager, "Seedance2Pro DB query failed", err.into(), None);
}
};
let total_pending_jobs = pending_jobs.len();
if pending_jobs.is_empty() {
info!("No pending database jobs found in the database.");
info!("No pending Seedance2Pro jobs.");
return Ok(());
}
info!("Found {} pending database job(s).", pending_jobs.len());
let result = website_polling_loop(&deps, pending_jobs).await?;
info!(
"Database + Kinovi poll iteration complete: \
{} total database jobs, \
{} total Kinovi pages, \
{} total Kinovi orders seen.",
total_pending_jobs,
result.total_pages_seen,
result.total_orders_seen
);
Ok(())
}
struct WebsitePollingResult {
total_pages_seen: u32,
total_orders_seen: u32,
}
async fn website_polling_loop(
deps: &&JobDependencies,
pending_jobs: Vec<PendingSeedance2ProJob>
) -> anyhow::Result<WebsitePollingResult> {
info!("Found {} pending Seedance2Pro job(s).", pending_jobs.len());
// Build a lookup: order_id -> job. This is mutated as batches are processed
// so that each order is handled at most once.
let mut job_by_order_id: HashMap<String, _> = pending_jobs
.into_iter()
.map(|job| (job.order_id.clone(), job))
.collect();
.into_iter()
.map(|job| (job.order_id.clone(), job))
.collect();
// 2. Poll orders from Kinovi API, optionally processing in chunks.
// 2. Poll orders from seedance2pro API, optionally processing in chunks.
let mut cursor: Option<u64> = None;
let mut total_pages_seen: u32 = 0;
let mut total_page_number: u32 = 0;
let mut total_orders_seen: u32 = 0;
// Accumulates orders for the current batch (when chunking is enabled)
@@ -126,9 +88,12 @@ async fn website_polling_loop(
break;
}
total_page_number += 1;
pages_in_current_batch += 1;
info!(
"Beginning request for Kinovi page {}... (cursor: {:?}, total orders seen thus far: {})",
total_pages_seen, cursor, total_orders_seen
"Beginning request for page {}... (cursor={:?}, total_orders_so_far={})",
total_page_number, cursor, total_orders_seen
);
let response = match poll_orders(PollOrdersArgs {
@@ -138,85 +103,42 @@ async fn website_polling_loop(
}).await {
Ok(r) => r,
Err(err) => {
warn!("Error polling Kinovi orders: {:?}", err);
warn!("Error polling seedance2pro orders: {:?}", err);
return alert_pager_and_return_err(
&deps.pager,
"Kinovi API polling failed",
"Seedance2Pro API poll failed",
anyhow::anyhow!("poll_orders failed: {:?}", err),
None,
);
}
};
let page_orders_count = response.orders.len() as u32;
total_pages_seen += 1;
total_orders_seen += page_orders_count;
pages_in_current_batch += 1;
let maybe_last_order_created_at = response.orders
.iter()
.filter(|order| order.created_at_utc.is_some())
.last()
.and_then(|order| order.created_at_utc);
info!(
"Done polling Kinovi orders page {}. \
Got {} orders on this page (oldest on page created at {:?}).",
total_pages_seen,
page_orders_count,
maybe_last_order_created_at,
);
// Check if the last (oldest) order in this page exceeds the max age threshold.
// Orders are returned newest-first, so the last order is the oldest.
let mut exceeded_max_age = false;
if let Some(ref max_age) = deps.maybe_max_job_age {
if let Some(last_order_created_at) = maybe_last_order_created_at {
let order_age = Utc::now() - last_order_created_at;
let too_old = order_age > *max_age;
if too_old {
info!(
"Last order on Kinovi order page {} is {} hours old \
(staleness threshold: {} hours). Stopping Kinovi pagination.",
total_pages_seen,
order_age.num_hours(),
max_age.num_hours()
);
exceeded_max_age = true;
}
}
};
let page_count = response.orders.len() as u32;
info!("Done polling page {}. Got {} orders on this page.", total_page_number, page_count);
total_orders_seen += page_count;
batch_orders.extend(response.orders);
cursor = response.next_cursor;
let reached_end = cursor.is_none() || exceeded_max_age;
let batch_is_full = deps.maybe_pages_per_batch
.map(|batch_page_limit| pages_in_current_batch >= batch_page_limit)
.unwrap_or(false);
let reached_end = cursor.is_none();
// Determine if we should process the current batch now.
let should_process_batch_now = reached_end || batch_is_full;
let should_process_batch = reached_end
|| deps.maybe_pages_per_batch
.map(|limit| pages_in_current_batch >= limit)
.unwrap_or(false);
if should_process_batch_now && !batch_orders.is_empty() {
process_orders_batch(
deps,
&batch_orders,
&mut job_by_order_id,
pages_in_current_batch
).await;
if should_process_batch && !batch_orders.is_empty() {
log_batch_summary(&batch_orders, pages_in_current_batch);
process_page_batch(deps, &batch_orders, &mut job_by_order_id).await;
batch_orders.clear();
pages_in_current_batch = 0;
// If all pending jobs have been matched, no need to keep paging.
if job_by_order_id.is_empty() {
info!("All pending database jobs have been matched to Kinovi orders and processed. Stopping pagination early.");
info!("All pending jobs have been matched. Stopping pagination early.");
break;
}
}
@@ -226,9 +148,31 @@ async fn website_polling_loop(
}
}
Ok(WebsitePollingResult {
total_pages_seen,
total_orders_seen
})
info!(
"Poll iteration complete: {} total pages, {} total orders seen.",
total_page_number, total_orders_seen
);
Ok(())
}
fn log_batch_summary(orders: &[OrderStatus], pages_in_batch: u32) {
let mut succeeded = 0u32;
let mut failed = 0u32;
let mut in_progress = 0u32;
let mut unknown = 0u32;
for order in orders {
match &order.task_status {
TaskStatus::Completed => succeeded += 1,
TaskStatus::Failed => failed += 1,
TaskStatus::Pending | TaskStatus::Processing => in_progress += 1,
TaskStatus::Unknown(_) => unknown += 1,
}
}
info!(
"Processing batch of {} pages, {} orders (succeeded={}, failed={}, in_progress={}, unknown={})",
pages_in_batch, orders.len(), succeeded, failed, in_progress, unknown
);
}
@@ -9,13 +9,11 @@
#[macro_use] extern crate serde_derive;
use std::sync::Arc;
use std::time::Duration;
use anyhow::anyhow;
use log::{info, warn};
use sqlx::mysql::MySqlPoolOptions;
use tokio::sync::Notify;
use bootstrap::bootstrap::{bootstrap, BootstrapArgs};
use cloud_storage::bucket_client::BucketClient;
@@ -29,7 +27,6 @@ use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
use crate::http_server::run_http_server::{launch_http_server, CreateServerArgs};
use crate::jobs::character_polling_job::character_polling_main_loop::character_polling_main_loop;
use crate::jobs::credits_checking_job::credits_checking_main_loop::credits_checking_main_loop;
use crate::jobs::video_polling_job::video_polling_main_loop::video_polling_main_loop;
use crate::job_dependencies::JobDependencies;
use crate::startup::build_pager::build_pager;
@@ -46,7 +43,6 @@ const ENV_REGION_NAME: &str = "REGION_NAME";
const ENV_PUBLIC_BUCKET_NAME: &str = "PUBLIC_BUCKET_NAME";
const ENV_S3_ENDPOINT: &str = "S3_COMPATIBLE_ENDPOINT_URL";
const ENV_SEEDANCE2PRO_COOKIES : &str = "SEEDANCE2PRO_COOKIES";
const ENV_MAX_JOB_AGE_THRESHOLD_HOURS: &str = "MAX_JOB_AGE_THRESHOLD_HOURS";
#[tokio::main]
async fn main() -> AnyhowResult<()> {
@@ -117,20 +113,6 @@ async fn main() -> AnyhowResult<()> {
info!("Batch page count: {}", count);
}
let maybe_max_job_age = easyenv::try_get_env_num_optional::<i64>(ENV_MAX_JOB_AGE_THRESHOLD_HOURS)?
.and_then(chrono::Duration::try_hours);
if let Some(ref duration) = maybe_max_job_age {
info!("Max job age threshold: {} hours", duration.num_hours());
}
let credits_alert_threshold: u64 = easyenv::get_env_num(
"CREDITS_ALERT_THRESHOLD",
10_000,
)?;
info!("Credits alert threshold: {}", credits_alert_threshold);
let (pager, pager_worker) = build_pager(server_environment, &container_environment.hostname);
info!("Spawning pager worker.");
@@ -144,7 +126,6 @@ async fn main() -> AnyhowResult<()> {
});
let application_shutdown = RelaxedAtomicBool::new(false);
let shutdown_notify = Arc::new(Notify::new());
let job_stats = JobStats::new();
let pager_for_shutdown = pager.clone();
@@ -163,10 +144,7 @@ async fn main() -> AnyhowResult<()> {
job_stats,
poll_interval_millis,
maybe_pages_per_batch,
maybe_max_job_age,
credits_alert_threshold,
application_shutdown: application_shutdown.clone(),
shutdown_notify: shutdown_notify.clone(),
pager,
};
@@ -183,14 +161,12 @@ async fn main() -> AnyhowResult<()> {
// Listen for SIGTERM / Ctrl-C to trigger graceful shutdown.
let application_shutdown_for_signal = application_shutdown.clone();
let shutdown_notify_for_signal = shutdown_notify.clone();
tokio::spawn(async move {
match tokio::signal::ctrl_c().await {
Ok(()) => {
info!("Received shutdown signal. Shutting down...");
application_shutdown_for_signal.set(true);
shutdown_notify_for_signal.notify_waiters();
}
Err(err) => {
warn!("Error listening for shutdown signal: {:?}", err);
@@ -198,10 +174,9 @@ async fn main() -> AnyhowResult<()> {
}
});
// Spawn all polling loops as concurrent tasks.
// Spawn both polling loops as concurrent tasks.
let video_deps = job_dependencies.clone();
let character_deps = job_dependencies.clone();
let credits_deps = job_dependencies;
let character_deps = job_dependencies;
let video_handle = tokio::spawn(async move {
video_polling_main_loop(video_deps).await;
@@ -211,12 +186,8 @@ async fn main() -> AnyhowResult<()> {
character_polling_main_loop(character_deps).await;
});
let credits_handle = tokio::spawn(async move {
credits_checking_main_loop(credits_deps).await;
});
// Wait for all to finish (they exit when application_shutdown is set).
let _ = tokio::join!(video_handle, character_handle, credits_handle);
// Wait for both to finish (they exit when application_shutdown is set).
let _ = tokio::join!(video_handle, character_handle);
info!("Shutting down pager worker...");
pager_for_shutdown.shutdown_worker();
@@ -50,7 +50,6 @@ datetimes = { path = "../../../lib/data/datetimes" }
easyenv.workspace = true
elasticsearch_schema = { path = "../../../schema/database/elasticsearch_schema" }
enums.workspace = true
error_utils.workspace = true
errors.workspace = true
fal_client.workspace = true
filesys.workspace = true
@@ -603,8 +603,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
AppStateSubscriptionProductKey,
AppStateUserInfo,
AppStateUserLocale,
BatchGetInferenceJobStatusError,
BatchGetInferenceJobStatusQueryParams,
BatchGetInferenceJobStatusSuccessResponse,
BatchGetMediaFilesError,
BatchGetMediaFilesModelInfo,
CreatePromptRequest,
CreatePromptResponse,
@@ -837,11 +839,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
GenerateVeo2Duration,
GenerateVeo2ImageToVideoRequest,
GenerateVeo2ImageToVideoResponse,
GetInferenceJobStatusError,
GetInferenceJobStatusPathInfo,
GetInferenceJobStatusSuccessResponse,
GetIsFeaturedItemError,
GetIsFeaturedItemPathInfo,
GetIsFeaturedItemSuccessResponse,
GetMediaFileError,
GetMediaFileModelInfo,
GetMediaFileModeratorFields,
GetMediaFilePathInfo,
@@ -880,18 +884,23 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListDatasetsByUserError,
ListDatasetsByUserPathInfo,
ListDatasetsByUserSuccessResponse,
ListFeaturedMediaFilesError,
ListFeaturedMediaFilesQueryParams,
ListFeaturedMediaFilesSuccessResponse,
ListFeaturedWeightsError,
ListFeaturedWeightsQueryParams,
ListFeaturedWeightsSuccessResponse,
ListMediaFilesByBatchError,
ListMediaFilesByBatchPathInfo,
ListMediaFilesByBatchSuccessResponse,
ListMediaFilesError,
ListMediaFilesForUserError,
ListMediaFilesForUserPathInfo,
ListMediaFilesForUserQueryParams,
ListMediaFilesForUserSuccessResponse,
ListMediaFilesQueryParams,
ListMediaFilesSuccessResponse,
ListPinnedMediaFilesError,
ListPinnedMediaFilesSuccessResponse,
ListPinnedWeightsError,
ListPinnedWeightsSuccessResponse,
@@ -239,7 +239,7 @@ async fn upload_to_kinovi(
.await
.map_err(|err| {
warn!("Error downloading media file from CDN: {:?}", err);
AdvancedCommonWebError::from_error(err)
AdvancedCommonWebError::from_anyhow_error(err)
})?
.to_vec();
@@ -683,7 +683,7 @@ async fn upload_to_seedance2pro(
.await
.map_err(|err| {
warn!("Error downloading media file from CDN: {:?}", err);
AdvancedCommonWebError::from_error(err)
AdvancedCommonWebError::from_anyhow_error(err)
})?
.to_vec();
@@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::fmt;
use std::sync::Arc;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::inference_job::utils::estimates::estimate_job_progress::estimate_job_progress;
@@ -10,9 +10,12 @@ use crate::http_server::endpoints::inference_job::utils::extractors::extract_liv
use crate::http_server::endpoints::inference_job::utils::extractors::extract_polymorphic_inference_args::extract_polymorphic_inference_args;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::filter_model_name::maybe_filter_model_name;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use actix_web::{web, HttpRequest, HttpResponse};
use actix_web_lab::extract::Query;
use artcraft_api_defs::common::responses::job_details::{JobDetailsLipsyncRequest, JobDetailsLivePortraitRequest};
use artcraft_api_defs::common::responses::media_links::MediaLinks;
@@ -23,7 +26,7 @@ use enums::by_table::generic_inference_jobs::frontend_failure_category::Frontend
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::job_status_plus::JobStatusPlus;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use log::{error, warn};
use log::error;
use mysql_queries::queries::generic_inference::web::batch_get_inference_job_status::batch_get_inference_job_status;
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use redis::Commands;
@@ -140,6 +143,37 @@ pub struct BatchResultDetailsResponse {
pub maybe_successfully_completed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, ToSchema)]
pub enum BatchGetInferenceJobStatusError {
ServerError,
NotFound,
}
impl ResponseError for BatchGetInferenceJobStatusError {
fn status_code(&self) -> StatusCode {
match *self {
BatchGetInferenceJobStatusError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
BatchGetInferenceJobStatusError::NotFound => StatusCode::NOT_FOUND,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
Self::ServerError => "server error".to_string(),
Self::NotFound => "not found".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for BatchGetInferenceJobStatusError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
/// Get job statuses for a batch of multiple job tokens.
#[utoipa::path(
get,
@@ -147,7 +181,7 @@ pub struct BatchResultDetailsResponse {
path = "/v1/jobs/batch",
responses(
(status = 200, body = BatchGetInferenceJobStatusSuccessResponse),
(status = 500),
(status = 500, body = BatchGetInferenceJobStatusError),
),
params(
BatchGetInferenceJobStatusQueryParams
@@ -156,8 +190,8 @@ pub struct BatchResultDetailsResponse {
pub async fn batch_get_inference_job_status_handler(
http_request: HttpRequest,
query: Query<BatchGetInferenceJobStatusQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<BatchGetInferenceJobStatusSuccessResponse>, AdvancedCommonWebError> {
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<BatchGetInferenceJobStatusSuccessResponse>, BatchGetInferenceJobStatusError> {
let tokens = query.tokens.iter()
.map(|token| token.trim())
@@ -171,27 +205,31 @@ pub async fn batch_get_inference_job_status_handler(
if tokens.is_empty() {
// NB: MediaDomain doesn't matter since it's an empty list.
return Ok(records_to_response(
Vec::new(),
server_state.server_environment,
Vec::new(),
server_state.server_environment,
MediaDomain::Storyteller
));
}
// NB: Since this is publicly exposed, we don't query sensitive data.
let records = batch_get_inference_job_status(
let maybe_records = batch_get_inference_job_status(
&tokens,
&server_state.mysql_pool
).await
.map_err(|err| {
warn!("Batch job query error: {:?}", err);
AdvancedCommonWebError::from_anyhow_error(err)
})?;
).await;
let records = match maybe_records {
Ok(records) => records,
Err(err) => {
error!("tts job query error: {:?}", err);
return Err(BatchGetInferenceJobStatusError::ServerError);
}
};
let mut redis = server_state.redis_pool
.get()
.map_err(|e| {
warn!("Redis pool error: {:?}", e);
AdvancedCommonWebError::from_error(e)
error!("redis error: {:?}", e);
BatchGetInferenceJobStatusError::ServerError
})?;
// TODO(bt,2024-04-22): Look up the extra redis statuses per item.
@@ -1,6 +1,6 @@
use std::fmt;
use std::sync::Arc;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::inference_job::utils::estimates::estimate_job_progress::estimate_job_progress;
@@ -9,9 +9,12 @@ use crate::http_server::endpoints::inference_job::utils::extractors::extract_liv
use crate::http_server::endpoints::inference_job::utils::extractors::extract_polymorphic_inference_args::extract_polymorphic_inference_args;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::filter_model_name::maybe_filter_model_name;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use actix_web::{web, HttpRequest, HttpResponse};
use artcraft_api_defs::common::responses::job_details::{JobDetailsLipsyncRequest, JobDetailsLivePortraitRequest};
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -21,7 +24,7 @@ use enums::by_table::generic_inference_jobs::frontend_failure_category::Frontend
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::job_status_plus::JobStatusPlus;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use log::{error, warn};
use log::error;
use mysql_queries::queries::generic_inference::web::get_inference_job_status::get_inference_job_status;
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use redis::{Commands, RedisResult};
@@ -131,6 +134,37 @@ pub struct ResultDetailsResponse {
pub maybe_successfully_completed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, ToSchema)]
pub enum GetInferenceJobStatusError {
ServerError,
NotFound,
}
impl ResponseError for GetInferenceJobStatusError {
fn status_code(&self) -> StatusCode {
match *self {
GetInferenceJobStatusError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
GetInferenceJobStatusError::NotFound => StatusCode::NOT_FOUND,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
Self::ServerError => "server error".to_string(),
Self::NotFound => "not found".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for GetInferenceJobStatusError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
/// Get the status for a single job.
#[utoipa::path(
get,
@@ -141,20 +175,19 @@ pub struct ResultDetailsResponse {
),
responses(
(status = 200, body = GetInferenceJobStatusSuccessResponse),
(status = 404),
(status = 500),
(status = 500, body = GetInferenceJobStatusError),
),
)]
pub async fn get_inference_job_status_handler(
http_request: HttpRequest,
path: Path<GetInferenceJobStatusPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<GetInferenceJobStatusSuccessResponse>, AdvancedCommonWebError> {
server_state: web::Data<Arc<ServerState>>) -> Result<Json<GetInferenceJobStatusSuccessResponse>, GetInferenceJobStatusError>
{
if path.token.as_str().trim() == "None" {
// NB: A bunch of Python clients use our API and can fail in this manner.
// This was a large traffic driver during the 2023-03-08 outage.
// Presumably, it's this client: https://github.com/shards-7/fakeyou.py
return Err(AdvancedCommonWebError::NotFound);
// Presumably it's this client: https://github.com/shards-7/fakeyou.py
return Err(GetInferenceJobStatusError::NotFound);
}
// NB: Since this is publicly exposed, we don't query sensitive data.
@@ -165,18 +198,18 @@ pub async fn get_inference_job_status_handler(
let record = match maybe_status {
Ok(Some(record)) => record,
Ok(None) => return Err(AdvancedCommonWebError::NotFound),
Ok(None) => return Err(GetInferenceJobStatusError::NotFound),
Err(err) => {
warn!("Job query error for token {}: {:?}", path.token.as_str(), err);
return Err(AdvancedCommonWebError::from_anyhow_error(err));
error!("tts job query error: {:?}", err);
return Err(GetInferenceJobStatusError::ServerError);
}
};
let mut redis = server_state.redis_pool
.get()
.map_err(|e| {
warn!("Redis pool error: {:?}", e);
AdvancedCommonWebError::from_error(e)
error!("redis error: {:?}", e);
GetInferenceJobStatusError::ServerError
})?;
// TODO(bt,2023-05-21): Make async.
@@ -1,8 +1,11 @@
use std::collections::HashSet;
use std::fmt;
use std::sync::Arc;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use actix_web::{web, HttpRequest, HttpResponse};
use actix_web_lab::extract::Query;
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -24,7 +27,6 @@ use tokens::tokens::model_weights::ModelWeightToken;
use tokens::tokens::prompts::PromptToken;
use utoipa::{IntoParams, ToSchema};
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
@@ -32,6 +34,7 @@ use crate::http_server::common_responses::user_details_lite::UserDetailsLight;
use crate::http_server::endpoints::media_files::common_responses::live_portrait::MediaFileLivePortraitDetails;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::state::server_state::ServerState;
// =============== Request ===============
@@ -194,6 +197,39 @@ pub struct BatchGetMediaFilesModelInfo {
pub maybe_weight_creator: Option<UserDetailsLight>,
}
// =============== Error Response ===============
#[derive(Debug, ToSchema)]
pub enum BatchGetMediaFilesError {
ServerError,
NotFound,
}
impl ResponseError for BatchGetMediaFilesError {
fn status_code(&self) -> StatusCode {
match *self {
BatchGetMediaFilesError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
BatchGetMediaFilesError::NotFound => StatusCode::NOT_FOUND,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
BatchGetMediaFilesError::ServerError => "server error".to_string(),
BatchGetMediaFilesError::NotFound => "not found".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for BatchGetMediaFilesError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
// =============== Handler ===============
/// List many media files at the same time by supplying their tokens. You'll have to provide the tokens.
@@ -203,8 +239,8 @@ pub struct BatchGetMediaFilesModelInfo {
path = "/v1/media_files/batch",
responses(
(status = 200, description = "Found", body = BatchGetMediaFilesSuccessResponse),
(status = 404, description = "Not found"),
(status = 500, description = "Server error"),
(status = 404, description = "Not found", body = BatchGetMediaFilesError),
(status = 500, description = "Server error", body = BatchGetMediaFilesError),
),
params(
BatchGetMediaFilesQueryParams,
@@ -214,11 +250,15 @@ pub async fn batch_get_media_files_handler(
http_request: HttpRequest,
query: Query<BatchGetMediaFilesQueryParams>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<BatchGetMediaFilesSuccessResponse>, AdvancedCommonWebError> {
) -> Result<Json<BatchGetMediaFilesSuccessResponse>, BatchGetMediaFilesError> {
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.await?;
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
BatchGetMediaFilesError::ServerError
})?;
let mut show_deleted_results = false;
let mut is_moderator = false;
@@ -248,7 +288,7 @@ pub async fn batch_get_media_files_handler(
Ok(media_files) => media_files,
Err(e) => {
warn!("query error: {:?}", e);
return Err(AdvancedCommonWebError::from_anyhow_error(e));
return Err(BatchGetMediaFilesError::ServerError);
}
};
@@ -1,6 +1,6 @@
use std::fmt;
use std::sync::Arc;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
@@ -10,9 +10,12 @@ use crate::http_server::endpoints::media_files::common_responses::live_portrait:
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::bucket_urls::bucket_url_from_media_path::bucket_url_from_media_path;
use crate::http_server::web_utils::bucket_urls::bucket_url_from_str_path::bucket_url_from_str_path;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use actix_web::{web, HttpRequest, HttpResponse};
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use chrono::{DateTime, Utc};
@@ -199,6 +202,37 @@ pub struct GetMediaFileModelInfo {
pub maybe_weight_creator: Option<UserDetailsLight>,
}
#[derive(Debug, ToSchema)]
pub enum GetMediaFileError {
ServerError,
NotFound,
}
impl ResponseError for GetMediaFileError {
fn status_code(&self) -> StatusCode {
match *self {
GetMediaFileError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
GetMediaFileError::NotFound => StatusCode::NOT_FOUND,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
GetMediaFileError::ServerError => "server error".to_string(),
GetMediaFileError::NotFound => "not found".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for GetMediaFileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
/// Get a single media file by token.
#[utoipa::path(
get,
@@ -206,8 +240,8 @@ pub struct GetMediaFileModelInfo {
path = "/v1/media_files/file/{token}",
responses(
(status = 200, description = "Found", body = GetMediaFileSuccessResponse),
(status = 404, description = "Not found"),
(status = 500, description = "Server error"),
(status = 404, description = "Not found", body = GetMediaFileError),
(status = 500, description = "Server error", body = GetMediaFileError),
),
params(
("path" = GetMediaFilePathInfo, description = "Path for Request")
@@ -217,13 +251,17 @@ pub async fn get_media_file_handler(
http_request: HttpRequest,
path: Path<GetMediaFilePathInfo>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<GetMediaFileSuccessResponse>, AdvancedCommonWebError> {
) -> Result<Json<GetMediaFileSuccessResponse>, GetMediaFileError> {
let media_file_token = path.into_inner().token;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.await?;
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
GetMediaFileError::ServerError
})?;
let mut show_deleted_results = false;
let mut is_moderator = false;
@@ -267,7 +305,7 @@ async fn modern_media_file_lookup(
show_deleted_results: bool,
server_state: &ServerState,
media_domain: MediaDomain,
) -> Result<GetMediaFileSuccessResponse, AdvancedCommonWebError> {
) -> Result<GetMediaFileSuccessResponse, GetMediaFileError> {
let is_mod = show_deleted_results;
@@ -280,9 +318,9 @@ async fn modern_media_file_lookup(
let result = match result {
Err(e) => {
warn!("query error: {:?}", e);
return Err(AdvancedCommonWebError::from_anyhow_error(e));
return Err(GetMediaFileError::ServerError);
}
Ok(None) => return Err(AdvancedCommonWebError::NotFound),
Ok(None) => return Err(GetMediaFileError::NotFound),
Ok(Some(result)) => result,
};
@@ -330,7 +368,7 @@ async fn modern_media_file_lookup(
public_bucket_url: bucket_url_from_media_path(&public_bucket_path, media_domain, server_state.server_environment)
.map_err(|err| {
warn!("error creating URL: {:?}", err);
AdvancedCommonWebError::from_anyhow_error(err)
GetMediaFileError::ServerError
})?,
public_bucket_path: public_bucket_path
.get_full_object_path_str()
@@ -415,7 +453,7 @@ async fn emulate_media_file_with_legacy_tts_result_lookup(
show_deleted_results: bool,
server_state: &ServerState,
media_domain: MediaDomain,
) -> Result<GetMediaFileSuccessResponse, AdvancedCommonWebError> {
) -> Result<GetMediaFileSuccessResponse, GetMediaFileError> {
let result = select_tts_result_by_token(
&media_file_token.as_str(),
@@ -426,9 +464,9 @@ async fn emulate_media_file_with_legacy_tts_result_lookup(
let result = match result {
Err(e) => {
warn!("query error (legacy TTS): {:?}", e);
return Err(AdvancedCommonWebError::from_anyhow_error(e));
return Err(GetMediaFileError::ServerError);
}
Ok(None) => return Err(AdvancedCommonWebError::NotFound),
Ok(None) => return Err(GetMediaFileError::NotFound),
Ok(Some(result)) => result,
};
@@ -468,7 +506,7 @@ async fn emulate_media_file_with_legacy_tts_result_lookup(
public_bucket_url: bucket_url_from_str_path(&public_bucket_path, media_domain, server_state.server_environment)
.map_err(|err| {
warn!("error creating URL: {:?}", err);
AdvancedCommonWebError::from_anyhow_error(err)
GetMediaFileError::ServerError
})?,
public_bucket_path,
cover_image: MediaFileCoverImageDetails::from_legacy_token_str(&result.tts_result_token),
@@ -1,14 +1,19 @@
use std::collections::HashSet;
use std::fmt;
use std::sync::Arc;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::media::media_file_cover_image_details_builder::MediaFileCoverImageDetailsBuilder;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::common_responses::user_details_lite_builder::UserDetailsLightBuilder;
use crate::http_server::endpoints::media_files::get::get_media_file_handler::GetMediaFilePathInfo;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use actix_web::{web, HttpRequest, HttpResponse};
use actix_web_lab::extract::Query;
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use artcraft_api_defs::common::responses::simple_entity_stats::SimpleEntityStats;
@@ -40,7 +45,7 @@ pub async fn list_batch_generated_redux_media_files_handler(
http_request: HttpRequest,
path: Path<ListBatchGeneratedReduxMediaFilesPathInfo>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<ListBatchGeneratedReduxMediaFilesSuccessResponse>, AdvancedCommonWebError> {
) -> Result<Json<ListBatchGeneratedReduxMediaFilesSuccessResponse>, CommonWebError> {
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await?;
@@ -48,7 +53,11 @@ pub async fn list_batch_generated_redux_media_files_handler(
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await?;
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
CommonWebError::ServerError
})?;
let mut show_deleted_results = false;
let mut is_moderator = false;
@@ -74,7 +83,7 @@ pub async fn list_batch_generated_redux_media_files_handler(
Ok(batch) => batch.media_files,
Err(e) => {
warn!("query error: {:?}", e);
return Err(AdvancedCommonWebError::from_anyhow_error(e));
return Err(CommonWebError::ServerError);
}
};
@@ -1,3 +1,5 @@
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::Query;
use actix_web::{web, HttpRequest, HttpResponse};
use actix_web_lab::__reexports::tracing::info;
@@ -21,7 +23,6 @@ use time::ext::InstantExt;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::{IntoParams, ToSchema};
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::common_responses::media_file_origin_details::MediaFileOriginDetails;
@@ -181,6 +182,27 @@ pub struct FeaturedMediaFile {
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, ToSchema)]
pub enum ListFeaturedMediaFilesError {
NotAuthorized,
ServerError,
}
impl std::fmt::Display for ListFeaturedMediaFilesError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl ResponseError for ListFeaturedMediaFilesError {
fn status_code(&self) -> StatusCode {
match *self {
ListFeaturedMediaFilesError::NotAuthorized => StatusCode::UNAUTHORIZED,
ListFeaturedMediaFilesError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
/// List media files that were featured or promoted by the staff (paginated). This will power our global asset drawers and highlight reels.
#[utoipa::path(
get,
@@ -189,15 +211,15 @@ pub struct FeaturedMediaFile {
params(ListFeaturedMediaFilesQueryParams),
responses(
(status = 200, description = "List Featured Media Files", body = ListFeaturedMediaFilesSuccessResponse),
(status = 401, description = "Not authorized"),
(status = 500, description = "Server error"),
(status = 401, description = "Not authorized", body = ListFeaturedMediaFilesError),
(status = 500, description = "Server error", body = ListFeaturedMediaFilesError),
),
)]
pub async fn list_featured_media_files_handler(
http_request: HttpRequest,
query: Query<ListFeaturedMediaFilesQueryParams>,
server_state: web::Data<Arc<ServerState>>
) -> Result<HttpResponse, AdvancedCommonWebError> {
) -> Result<HttpResponse, ListFeaturedMediaFilesError> {
let cache_start = Instant::now();
@@ -224,14 +246,22 @@ pub async fn list_featured_media_files_handler(
};
let cursor_next = if let Some(id) = results_page.last_id {
let cursor = server_state.sort_key_crypto.encrypt_id(id as u64)?;
let cursor = server_state.sort_key_crypto.encrypt_id(id as u64)
.map_err(|e| {
warn!("crypto error: {:?}", e);
ListFeaturedMediaFilesError::ServerError
})?;
Some(cursor)
} else {
None
};
let cursor_previous = if let Some(id) = results_page.first_id {
let cursor = server_state.sort_key_crypto.encrypt_id(id as u64)?;
let cursor = server_state.sort_key_crypto.encrypt_id(id as u64)
.map_err(|e| {
warn!("crypto error: {:?}", e);
ListFeaturedMediaFilesError::ServerError
})?;
Some(cursor)
} else {
None
@@ -319,7 +349,8 @@ pub async fn list_featured_media_files_handler(
}
};
let body = serde_json::to_string(&response)?;
let body = serde_json::to_string(&response)
.map_err(|e| ListFeaturedMediaFilesError::ServerError)?;
let mut response_builder = HttpResponse::Ok();
@@ -337,7 +368,7 @@ async fn database_lookup(
query: &ListFeaturedMediaFilesQueryParams,
server_state: &ServerState,
maybe_cloudflare_header: Option<&str>,
) -> Result<FeaturedMediaFileListPage, AdvancedCommonWebError> {
) -> Result<FeaturedMediaFileListPage, ListFeaturedMediaFilesError> {
// TODO(bt,2023-12-04): Enforce real maximums and defaults
let limit = query.page_size.unwrap_or(25);
@@ -346,7 +377,11 @@ async fn database_lookup(
let cursor_is_reversed = query.cursor_is_reversed.unwrap_or(false);
let cursor = if let Some(cursor) = query.cursor.as_deref() {
let cursor = server_state.sort_key_crypto.decrypt_id(cursor)?;
let cursor = server_state.sort_key_crypto.decrypt_id(cursor)
.map_err(|e| {
warn!("crypto error: {:?}", e);
ListFeaturedMediaFilesError::ServerError
})?;
Some(cursor as usize)
} else {
None
@@ -376,7 +411,7 @@ async fn database_lookup(
mysql_pool: &server_state.mysql_pool,
}).await.map_err(|err| {
error!("DB error: {:?}", err);
AdvancedCommonWebError::from_anyhow_error(err)
ListFeaturedMediaFilesError::ServerError
})?;
let query_duration = Instant::now().signed_duration_since(query_start);
@@ -1,7 +1,9 @@
use std::sync::Arc;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::{Json, Path, Query};
use actix_web::{web, HttpRequest};
use actix_web::{web, HttpRequest, HttpResponse};
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use chrono::{DateTime, Utc};
@@ -21,7 +23,6 @@ use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::{IntoParams, ToSchema};
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::common_responses::media_file_origin_details::MediaFileOriginDetails;
@@ -29,6 +30,7 @@ use crate::http_server::common_responses::pagination_page::PaginationPage;
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::state::server_state::ServerState;
use crate::util::allowed_studio_access::allowed_studio_access;
@@ -138,6 +140,33 @@ pub struct MediaFilesByBatchListItem {
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, ToSchema)]
pub enum ListMediaFilesByBatchError {
ServerError,
}
impl ResponseError for ListMediaFilesByBatchError {
fn status_code(&self) -> StatusCode {
match *self {
ListMediaFilesByBatchError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
ListMediaFilesByBatchError::ServerError => "server error".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
impl std::fmt::Display for ListMediaFilesByBatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
/// List media files that were generated together in a "batch", e.g. stable diffusion outputs.
///
/// This uses a special "batch token" that these jobs return. Media files created together will also
@@ -151,7 +180,7 @@ pub struct MediaFilesByBatchListItem {
),
responses(
(status = 200, description = "List Media Files by Batch", body = ListMediaFilesByBatchSuccessResponse),
(status = 500, description = "Server error"),
(status = 500, description = "Server error", body = ListMediaFilesByBatchError),
),
)]
pub async fn list_media_files_by_batch_token_handler(
@@ -159,12 +188,16 @@ pub async fn list_media_files_by_batch_token_handler(
path: Path<ListMediaFilesByBatchPathInfo>,
query: Query<ListMediaFilesByBatchQueryParams>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<ListMediaFilesByBatchSuccessResponse>, AdvancedCommonWebError>
) -> Result<Json<ListMediaFilesByBatchSuccessResponse>, ListMediaFilesByBatchError>
{
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.await?;
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
ListMediaFilesByBatchError::ServerError
})?;
// NB: Temporary rollout flag for certain file types (BVH, etc).
let mut is_allowed_studio_access = allowed_studio_access(
@@ -193,7 +226,7 @@ pub async fn list_media_files_by_batch_token_handler(
Ok(results) => results,
Err(e) => {
warn!("Query error: {:?}", e);
return Err(AdvancedCommonWebError::from_anyhow_error(e));
return Err(ListMediaFilesByBatchError::ServerError);
}
};
@@ -1,6 +1,8 @@
use std::sync::Arc;
use actix_web::web::{Json, Path, Query};
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::{Path, Query};
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -20,7 +22,6 @@ use mysql_queries::queries::media_files::list::list_media_files_for_user::{list_
use tokens::tokens::media_files::MediaFileToken;
use utoipa::{IntoParams, ToSchema};
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::common_responses::media_file_origin_details::MediaFileOriginDetails;
@@ -31,6 +32,7 @@ use crate::http_server::endpoints::media_files::helpers::get_scoped_engine_categ
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_classes::get_scoped_media_classes;
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_types::get_scoped_media_types;
use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::state::server_state::ServerState;
use crate::util::allowed_studio_access::allowed_studio_access;
@@ -173,6 +175,33 @@ pub struct MediaFileForUserListItem {
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, ToSchema)]
pub enum ListMediaFilesForUserError {
ServerError,
}
impl ResponseError for ListMediaFilesForUserError {
fn status_code(&self) -> StatusCode {
match *self {
ListMediaFilesForUserError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
ListMediaFilesForUserError::ServerError => "server error".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
impl std::fmt::Display for ListMediaFilesForUserError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
/// List all of a user's media files (paginated).
///
/// This endpoint uses the session to automatically show all files for the given user, but only
@@ -186,7 +215,7 @@ pub struct MediaFileForUserListItem {
),
responses(
(status = 200, description = "List Featured Media Files", body = ListMediaFilesForUserSuccessResponse),
(status = 500, description = "Server error"),
(status = 500, description = "Server error", body = ListMediaFilesForUserError),
),
)]
pub async fn list_media_files_for_user_handler(
@@ -194,12 +223,16 @@ pub async fn list_media_files_for_user_handler(
path: Path<ListMediaFilesForUserPathInfo>,
query: Query<ListMediaFilesForUserQueryParams>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<ListMediaFilesForUserSuccessResponse>, AdvancedCommonWebError>
) -> Result<HttpResponse, ListMediaFilesForUserError>
{
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.await?;
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
ListMediaFilesForUserError::ServerError
})?;
let mut is_author = false;
let mut is_mod = false;
@@ -254,7 +287,7 @@ pub async fn list_media_files_for_user_handler(
Ok(results) => results,
Err(e) => {
warn!("Query error: {:?}", e);
return Err(AdvancedCommonWebError::from_anyhow_error(e));
return Err(ListMediaFilesForUserError::ServerError);
}
};
@@ -339,12 +372,19 @@ pub async fn list_media_files_for_user_handler(
})
.collect::<Vec<_>>();
Ok(Json(ListMediaFilesForUserSuccessResponse {
let response = ListMediaFilesForUserSuccessResponse {
success: true,
results,
pagination: PaginationPage {
pagination: PaginationPage{
current: results_page.current_page,
total_page_count: results_page.total_page_count,
}
}))
};
let body = serde_json::to_string(&response)
.map_err(|e| ListMediaFilesForUserError::ServerError)?;
Ok(HttpResponse::Ok()
.content_type("application/json")
.body(body))
}
@@ -1,6 +1,8 @@
use std::sync::Arc;
use actix_web::web::{Json, Query};
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::Query;
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
@@ -9,18 +11,17 @@ use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationT
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::view_as::ViewAs;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use log::warn;
use mysql_queries::queries::media_files::list::list_media_files::{list_media_files, ListMediaFilesArgs};
use tokens::tokens::media_files::MediaFileToken;
use utoipa::{IntoParams, ToSchema};
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::common_responses::media_file_origin_details::MediaFileOriginDetails;
@@ -32,6 +33,7 @@ use crate::http_server::endpoints::media_files::helpers::get_scoped_engine_categ
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_classes::get_scoped_media_classes;
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_types::get_scoped_media_types;
use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::state::server_state::ServerState;
use crate::util::allowed_explore_media_access::allowed_explore_media_access;
@@ -172,6 +174,36 @@ pub struct MediaFileListItem {
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, ToSchema)]
pub enum ListMediaFilesError {
ServerError,
NotAuthorized,
}
impl ResponseError for ListMediaFilesError {
fn status_code(&self) -> StatusCode {
match *self {
ListMediaFilesError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
ListMediaFilesError::NotAuthorized => StatusCode::UNAUTHORIZED,
}
}
fn error_response(&self) -> HttpResponse {
let error_reason = match self {
ListMediaFilesError::ServerError => "server error".to_string(),
ListMediaFilesError::NotAuthorized => "not authorized".to_string(),
};
to_simple_json_error(&error_reason, self.status_code())
}
}
impl std::fmt::Display for ListMediaFilesError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
/// List all media files in the system globally, across all users (paginated).
///
/// This powers "explore files" page.
@@ -182,26 +214,29 @@ pub struct MediaFileListItem {
params(ListMediaFilesQueryParams),
responses(
(status = 200, description = "List Featured Media Files", body = ListMediaFilesSuccessResponse),
(status = 401, description = "Not authorized"),
(status = 500, description = "Server error"),
(status = 500, description = "Server error", body = ListMediaFilesError),
),
)]
pub async fn list_media_files_handler(
http_request: HttpRequest,
query: Query<ListMediaFilesQueryParams>,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<ListMediaFilesSuccessResponse>, AdvancedCommonWebError>
) -> Result<HttpResponse, ListMediaFilesError>
{
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.await?;
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
ListMediaFilesError::ServerError
})?;
// ==================== FEATURE FLAG CHECK ==================== //
if !allowed_explore_media_access(maybe_user_session.as_ref()) {
warn!("Explore media access is not permitted for user");
return Err(AdvancedCommonWebError::NotAuthorized);
return Err(ListMediaFilesError::NotAuthorized);
}
let mut is_mod = false;
@@ -220,7 +255,11 @@ pub async fn list_media_files_handler(
let cursor_is_reversed = query.cursor_is_reversed.unwrap_or(false);
let cursor = if let Some(cursor) = query.cursor.as_deref() {
let cursor = server_state.sort_key_crypto.decrypt_id(cursor)?;
let cursor = server_state.sort_key_crypto.decrypt_id(cursor)
.map_err(|e| {
warn!("crypto error: {:?}", e);
ListMediaFilesError::ServerError
})?;
Some(cursor as usize)
} else {
None
@@ -254,19 +293,27 @@ pub async fn list_media_files_handler(
Ok(results) => results,
Err(e) => {
warn!("Query error: {:?}", e);
return Err(AdvancedCommonWebError::from_anyhow_error(e));
return Err(ListMediaFilesError::ServerError);
}
};
let cursor_next = if let Some(id) = results_page.last_id {
let cursor = server_state.sort_key_crypto.encrypt_id(id as u64)?;
let cursor = server_state.sort_key_crypto.encrypt_id(id as u64)
.map_err(|e| {
warn!("crypto error: {:?}", e);
ListMediaFilesError::ServerError
})?;
Some(cursor)
} else {
None
};
let cursor_previous = if let Some(id) = results_page.first_id {
let cursor = server_state.sort_key_crypto.encrypt_id(id as u64)?;
let cursor = server_state.sort_key_crypto.encrypt_id(id as u64)
.map_err(|e| {
warn!("crypto error: {:?}", e);
ListMediaFilesError::ServerError
})?;
Some(cursor)
} else {
None
@@ -340,7 +387,7 @@ pub async fn list_media_files_handler(
})
.collect::<Vec<_>>();
Ok(Json(ListMediaFilesSuccessResponse {
let response = ListMediaFilesSuccessResponse {
success: true,
results,
pagination: PaginationCursors {
@@ -348,5 +395,12 @@ pub async fn list_media_files_handler(
maybe_previous: cursor_previous,
cursor_is_reversed,
}
}))
};
let body = serde_json::to_string(&response)
.map_err(|e| ListMediaFilesError::ServerError)?;
Ok(HttpResponse::Ok()
.content_type("application/json")
.body(body))
}
@@ -1,7 +1,8 @@
use std::sync::Arc;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::{web, HttpRequest, HttpResponse};
use actix_web::web::Json;
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use chrono::{DateTime, Utc};
@@ -19,7 +20,6 @@ use redis::Commands;
use tokens::tokens::media_files::MediaFileToken;
use utoipa::ToSchema;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::common_responses::media_file_origin_details::MediaFileOriginDetails;
@@ -122,6 +122,27 @@ pub struct PinnedMediaFile {
/// The key we store pinned media file tokens under
const REDIS_KEY : &str = "pinned_media_files_list";
#[derive(Debug, ToSchema)]
pub enum ListPinnedMediaFilesError {
NotAuthorized,
ServerError,
}
impl std::fmt::Display for ListPinnedMediaFilesError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl ResponseError for ListPinnedMediaFilesError {
fn status_code(&self) -> StatusCode {
match *self {
ListPinnedMediaFilesError::NotAuthorized => StatusCode::UNAUTHORIZED,
ListPinnedMediaFilesError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
/// List media files that were pinned by the staff (paginated).
#[utoipa::path(
get,
@@ -129,25 +150,25 @@ const REDIS_KEY : &str = "pinned_media_files_list";
path = "/v1/media_files/list_pinned",
responses(
(status = 200, description = "List Featured Media Files", body = ListPinnedMediaFilesSuccessResponse),
(status = 401, description = "Not authorized"),
(status = 500, description = "Server error"),
(status = 401, description = "Not authorized", body = ListPinnedMediaFilesError),
(status = 500, description = "Server error", body = ListPinnedMediaFilesError),
),
)]
pub async fn list_pinned_media_files_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>
) -> Result<Json<ListPinnedMediaFilesSuccessResponse>, AdvancedCommonWebError> {
) -> Result<HttpResponse, impl ResponseError> {
let mut redis = server_state.redis_pool.get()
.map_err(|err| {
error!("Could not obtain redis: {err}");
AdvancedCommonWebError::server_error_with_message("pinned media files error")
ListPinnedMediaFilesError::ServerError
})?;
let token_list : Option<String> = redis.get(REDIS_KEY)
.map_err(|err| {
error!("Could not get redis result: {err}");
AdvancedCommonWebError::server_error_with_message("pinned media files error")
ListPinnedMediaFilesError::ServerError
})?;
let media_file_tokens = token_list
@@ -171,14 +192,14 @@ pub async fn list_pinned_media_files_handler(
Ok(media_files) => media_files,
Err(e) => {
warn!("Query error: {:?}", e);
return Err(AdvancedCommonWebError::from_anyhow_error(e));
return Err(ListPinnedMediaFilesError::ServerError);
}
};
}
let media_domain = get_media_domain(&http_request);
Ok(Json(ListPinnedMediaFilesSuccessResponse {
let response = ListPinnedMediaFilesSuccessResponse {
success: true,
results: media_files.into_iter()
.map(|m| {
@@ -244,5 +265,12 @@ pub async fn list_pinned_media_files_handler(
updated_at: m.updated_at,
}
}).collect::<Vec<_>>(),
}))
};
let body = serde_json::to_string(&response)
.map_err(|e| ListPinnedMediaFilesError::ServerError)?;
Ok(HttpResponse::Ok()
.content_type("application/json")
.body(body))
}
@@ -58,7 +58,7 @@ pub async fn fal_webhook_handler(
// Step 4 & 5: Branch on the inner payload type.
let result = match hydrated_contents {
HydratedWebhookContents::Success(success_data) => {
handle_successful_fal_webhook(&server_state, request_id, &success_data, &server_state.pager).await
handle_successful_fal_webhook(&server_state, request_id, &success_data).await
}
HydratedWebhookContents::Error(error_data) => {
handle_failed_fal_webhook(
@@ -4,7 +4,7 @@ use http_server_common::response::response_success_helpers::SimpleGenericJsonSuc
use log::{info, warn};
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id;
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::{mark_fal_generic_inference_job_successfully_done, MarkJobArgs};
use pager::client::pager::Pager;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
@@ -18,7 +18,6 @@ pub async fn handle_successful_fal_webhook(
server_state: &ServerState,
request_id: &str,
success_data: &WebhookSuccessData,
pager: &Pager,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
let db_result = get_inference_job_by_fal_id(
@@ -54,7 +53,7 @@ pub async fn handle_successful_fal_webhook(
if let Some(ref images_data) = extracted.images {
info!("Handling images payload for request_id {} / job {:?}", request_id, job.job_token);
let (media_token, batch_token) = process_images_payload(images_data, &job, server_state, pager).await?;
let (media_token, batch_token) = process_images_payload(images_data, &job, server_state).await?;
if maybe_media_token.is_none() {
maybe_media_token = media_token;
}
@@ -35,7 +35,7 @@ pub async fn process_image_payload(
.await
.map_err(|err| {
warn!("Failed to download image from {}: {:?}", image_url, err);
AdvancedCommonWebError::from_error(err)
AdvancedCommonWebError::from_anyhow_error(err)
})?;
if file_bytes.len() <= 10 {
@@ -48,7 +48,7 @@ pub async fn process_image_payload(
.await
.map_err(|err| {
warn!("Failed to download image on retry from {}: {:?}", image_url, err);
AdvancedCommonWebError::from_error(err)
AdvancedCommonWebError::from_anyhow_error(err)
})?;
}
@@ -10,13 +10,10 @@ use fal_client::webhook_api::hydrated::hydrated_webhook_contents::ImagesData;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use images::encoding::webp_bytes_to_png_bytes::webp_bytes_to_png_bytes;
use images::image_info::image_info::ImageInfo;
use log::{error, info, warn};
use log::{info, warn};
use mimetypes::mimetype_info::file_extension::FileExtension;
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::FalJobDetails;
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use pager::client::pager::Pager;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -26,7 +23,6 @@ pub async fn process_images_payload(
images_data: &[ImagesData],
job: &FalJobDetails,
server_state: &ServerState,
pager: &Pager,
) -> Result<(Option<MediaFileToken>, Option<BatchGenerationToken>), AdvancedCommonWebError> {
let mut maybe_media_token = None;
@@ -39,57 +35,12 @@ pub async fn process_images_payload(
maybe_batch_token = Some(BatchGenerationToken::generate());
}
// NB: Fal has been failing for some images in batches of many images.
// Rather than fail the entire batch, let's skip the failure(s) and notify ourselves.
let mut success_count = 0;
let mut maybe_error = None;
for (i, image) in images_data.iter().enumerate() {
info!("Uploading image {} of {}: {:?}", i + 1, images_data.len(), image.url);
let result = upload_image(job, server_state, image, maybe_batch_token.as_ref()).await;
let media_token = match result {
Ok(token) => token,
Err(err) => {
maybe_error = Some(err);
continue;
}
};
for image in images_data {
let media_token = upload_image(job, server_state, image, maybe_batch_token.as_ref()).await?;
if maybe_media_token.is_none() {
maybe_media_token = Some(media_token); // Set the first media token
}
success_count += 1;
}
if success_count == 0 {
if let Some(err) = maybe_error {
return Err(AdvancedCommonWebError::from_error(err));
} else {
// NB: Branch should be unreachable.
return Err(AdvancedCommonWebError::server_error_with_message("none of the images could be processed"));
}
}
if let Some(err) = maybe_error {
// Even if some images were downloaded, let's still page about any failures.
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title(format!("Failure to download all images from FAL webhook: {} out of {} succeeded", success_count, images_data.len()))
.set_description(Some(format!(
"We uploaded all of the images we could and marked the job as a success, \
but the user may need assistance with the remaining images and/or reimbursement.\n\
**Internal Job Token**: {}\n\
**Fal ID**: {}\n",
job.job_token.as_str(),
job.external_third_party_id)))
.set_urgency(Some(NotificationUrgency::Medium))
.build();
if let Err(pager_err) = pager.enqueue_page(notification) {
error!("Failed to enqueue FAL webhook parse error alert: {:?}", pager_err);
}
}
Ok((maybe_media_token, maybe_batch_token))
@@ -72,7 +72,7 @@ pub async fn process_model_glb_payload(
.await
.map_err(|err| {
warn!("Failed to download mesh from {}: {:?}", mesh_url, err);
AdvancedCommonWebError::from_error(err)
AdvancedCommonWebError::from_anyhow_error(err)
})?;
if file_bytes.len() <= 10 {
@@ -33,7 +33,7 @@ pub async fn process_model_mesh_payload(
.await
.map_err(|err| {
warn!("Failed to download mesh from {}: {:?}", mesh_url, err);
AdvancedCommonWebError::from_error(err)
AdvancedCommonWebError::from_anyhow_error(err)
})?;
if file_bytes.len() <= 10 {
@@ -46,7 +46,7 @@ pub async fn process_model_mesh_payload(
.await
.map_err(|err| {
warn!("Failed to download mesh on retry from {}: {:?}", mesh_url, err);
AdvancedCommonWebError::from_error(err)
AdvancedCommonWebError::from_anyhow_error(err)
})?;
}
@@ -37,7 +37,7 @@ pub async fn process_video_payload(
.await
.map_err(|err| {
warn!("Failed to download video from {}: {:?}", video_url, err);
AdvancedCommonWebError::from_error(err)
AdvancedCommonWebError::from_anyhow_error(err)
})?;
if file_bytes.len() <= 10 {
@@ -50,7 +50,7 @@ pub async fn process_video_payload(
.await
.map_err(|err| {
warn!("Failed to download video on retry from {}: {:?}", video_url, err);
AdvancedCommonWebError::from_error(err)
AdvancedCommonWebError::from_anyhow_error(err)
})?;
}
@@ -1,4 +1,3 @@
use error_utils::try_error_name::try_error_name;
use log::{debug, warn};
use pager::client::pager::Pager;
@@ -22,13 +21,7 @@ pub(crate) fn check_advanced_common_web_error(
return true;
}
let maybe_error_name = error.cause()
.and_then(|cause| try_error_name(cause));
let title = match maybe_error_name {
Some(name) => format!("{} on {} {}", name, method, path),
None => format!("UncaughtServerError on {} {}", method, path),
};
let title = format!("UncaughtServerError on {} {}", method, path);
// Clone the Arc'd causal error so the notification owns a reference to it.
let cause_arc = error.clone_cause_arc();
@@ -1,25 +1,13 @@
use bytes::Bytes;
use log::warn;
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::{Client, StatusCode};
use errors::AnyhowResult;
use wreq::Client;
const USER_AGENT: &str = "storyteller-web/1.0";
// TODO(bt, 2025-06-03): Don't load the entire file into memory!
#[derive(Debug)]
pub enum DownloadFileToBytesError {
WreqError(wreq::Error),
BadResponseError {
status_code: StatusCode,
body: Bytes,
url: String,
}
}
/// Downloads a (binary) file to memory. Good for images, etc. Not great for large files.
pub async fn http_download_url_to_bytes(url: &str) -> Result<Bytes, DownloadFileToBytesError> {
pub async fn http_download_url_to_bytes(url: &str) -> AnyhowResult<Bytes> {
let client = Client::builder()
.gzip(true)
.build()?;
@@ -30,44 +18,7 @@ pub async fn http_download_url_to_bytes(url: &str) -> Result<Bytes, DownloadFile
.send()
.await?;
let status = response.status();
let bytes = response.bytes().await?;
if !status.is_success() {
warn!("Error downloading URL (status={}): {:?}", status.as_u16(), url);
return Err(DownloadFileToBytesError::BadResponseError {
status_code: status,
body: bytes,
url: url.to_string(),
});
}
Ok(bytes)
}
impl Error for DownloadFileToBytesError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
DownloadFileToBytesError::WreqError(e) => Some(e),
DownloadFileToBytesError::BadResponseError { .. } => None,
}
}
}
impl Display for DownloadFileToBytesError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
DownloadFileToBytesError::WreqError(e) => write!(f, "Wreq error: {}", e),
DownloadFileToBytesError::BadResponseError { status_code, body, url } => {
write!(f, "Bad response (status={}) fetching {} : {:?}", status_code.as_u16(), url, body)
}
}
}
}
impl From<wreq::Error> for DownloadFileToBytesError {
fn from(e: wreq::Error) -> Self {
DownloadFileToBytesError::WreqError(e)
}
}