mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
character api (#1250)
This commit is contained in:
+234
@@ -0,0 +1,234 @@
|
||||
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::generate_character::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;
|
||||
|
||||
// --- Public args ---
|
||||
|
||||
pub struct GenerateCharacterArgs<'a> {
|
||||
pub session: &'a Seedance2ProSession,
|
||||
|
||||
/// The name for this character.
|
||||
pub name: String,
|
||||
|
||||
/// A description of the character.
|
||||
pub description: String,
|
||||
|
||||
/// URL of the uploaded reference image (from `upload_file`).
|
||||
/// NB: The Kinovi API accepts multiple images, but the UI only supports one for now.
|
||||
/// We may expand this to Vec<String> in the future.
|
||||
pub reference_image_url: String,
|
||||
|
||||
/// Whether the character should be publicly visible.
|
||||
pub is_public: bool,
|
||||
|
||||
/// Override the default host (kinovi.ai).
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// --- Public response ---
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GenerateCharacterResponse {
|
||||
/// Internal numeric ID.
|
||||
pub id: u64,
|
||||
|
||||
/// The character identifier (e.g. "char_1774752056469_2wlxoq").
|
||||
pub character_id: String,
|
||||
|
||||
/// The name that was assigned.
|
||||
pub name: String,
|
||||
|
||||
/// ISO 8601 timestamp of creation.
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// --- Implementation ---
|
||||
|
||||
pub async fn generate_character(args: GenerateCharacterArgs<'_>) -> Result<GenerateCharacterResponse, Seedance2ProError> {
|
||||
let host = resolve_host(args.host_override.as_ref());
|
||||
let base_url = host.api_base_url();
|
||||
let url = format!("{}/api/trpc/character.createCharacter?batch=1", base_url);
|
||||
|
||||
info!("Creating character '{}'", args.name);
|
||||
|
||||
let request_body = BatchRequest {
|
||||
zero: BatchRequestInner {
|
||||
json: BatchRequestJson {
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
reference_image_urls: vec![args.reference_image_url],
|
||||
mode: "upload",
|
||||
is_public: args.is_public,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let cookie = args.session.cookies.as_str();
|
||||
let referer = format!("{}/app/characters", base_url);
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox143)
|
||||
.build()
|
||||
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
|
||||
|
||||
let response = client.post(&url)
|
||||
.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("Origin", base_url)
|
||||
.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")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
|
||||
|
||||
info!("Create character response: status={}, body={}", status, response_body);
|
||||
|
||||
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 data = batch_response
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
|
||||
"Empty batch response array".to_string()
|
||||
))?
|
||||
.result
|
||||
.data
|
||||
.json;
|
||||
|
||||
Ok(GenerateCharacterResponse {
|
||||
id: data.id,
|
||||
character_id: data.character_id,
|
||||
name: data.name,
|
||||
created_at: data.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[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 crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
|
||||
fn test_session() -> AnyhowResult<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
|
||||
async fn upload_image_from_url(session: &Seedance2ProSession, image_url: &str, extension: &str) -> AnyhowResult<String> {
|
||||
let image_bytes = crate::test_utils::http_download::http_download_to_bytes(image_url).await?;
|
||||
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension: extension.to_string(),
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes: image_bytes,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
Ok(upload_result.public_url)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies
|
||||
async fn test_create_character_juno() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
|
||||
let public_url = upload_image_from_url(
|
||||
&session,
|
||||
test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL,
|
||||
"jpg",
|
||||
).await?;
|
||||
|
||||
println!("Uploaded image: {}", public_url);
|
||||
|
||||
let result = generate_character(GenerateCharacterArgs {
|
||||
session: &session,
|
||||
name: "Juno".to_string(),
|
||||
description: "Juno the shiba inu at the lake".to_string(),
|
||||
reference_image_url: public_url,
|
||||
is_public: false,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
println!("Character ID: {}", result.character_id);
|
||||
println!("Name: {}", result.name);
|
||||
println!("Created at: {}", result.created_at);
|
||||
assert!(!result.character_id.is_empty());
|
||||
assert_eq!(result.name, "Juno");
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies
|
||||
async fn test_create_character_ernest() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
|
||||
let public_url = upload_image_from_url(
|
||||
&session,
|
||||
test_data::web::image_urls::ERNEST_SCARED_STUPID_IMAGE_URL,
|
||||
"jpg",
|
||||
).await?;
|
||||
|
||||
println!("Uploaded image: {}", public_url);
|
||||
|
||||
let result = generate_character(GenerateCharacterArgs {
|
||||
session: &session,
|
||||
name: "Ernest".to_string(),
|
||||
description: "Ernest".to_string(),
|
||||
reference_image_url: public_url,
|
||||
is_public: false,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
println!("Character ID: {}", result.character_id);
|
||||
println!("Name: {}", result.name);
|
||||
println!("Created at: {}", result.created_at);
|
||||
assert!(!result.character_id.is_empty());
|
||||
assert_eq!(result.name, "Ernest");
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod generate_character;
|
||||
pub(crate) mod request_types;
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
// ======================== Request ========================
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(super) struct BatchRequest {
|
||||
#[serde(rename = "0")]
|
||||
pub zero: BatchRequestInner,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(super) struct BatchRequestInner {
|
||||
pub json: BatchRequestJson,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(super) struct BatchRequestJson {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(rename = "referenceImageUrls")]
|
||||
pub reference_image_urls: Vec<String>,
|
||||
pub mode: &'static str,
|
||||
#[serde(rename = "isPublic")]
|
||||
pub is_public: bool,
|
||||
}
|
||||
|
||||
// ======================== Response ========================
|
||||
|
||||
#[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: CharacterResponseJson,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct CharacterResponseJson {
|
||||
pub id: u64,
|
||||
pub character_id: String,
|
||||
pub name: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod generate_character;
|
||||
pub mod generate_video;
|
||||
pub mod kinovi_host;
|
||||
pub mod poll_characters;
|
||||
pub mod poll_orders;
|
||||
pub mod prepare_file_upload;
|
||||
pub mod upload_file;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod poll_characters;
|
||||
pub(crate) mod request_types;
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
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::kinovi_host::{KinoviHost, resolve_host};
|
||||
use crate::requests::poll_characters::request_types::*;
|
||||
use crate::utils::common_headers::FIREFOX_USER_AGENT;
|
||||
use log::info;
|
||||
use wreq::Client;
|
||||
use wreq_util::Emulation;
|
||||
|
||||
// --- Public args ---
|
||||
|
||||
pub struct PollCharactersArgs<'a> {
|
||||
pub session: &'a Seedance2ProSession,
|
||||
|
||||
/// Cursor from a previous `PollCharactersResponse::next_cursor`.
|
||||
/// When `None`, the most recent characters are returned.
|
||||
pub cursor: Option<u64>,
|
||||
|
||||
/// Maximum number of characters to return per page. Defaults to 50.
|
||||
pub limit: Option<u32>,
|
||||
|
||||
/// Override the default host (kinovi.ai).
|
||||
pub host_override: Option<KinoviHost>,
|
||||
}
|
||||
|
||||
// --- Public response ---
|
||||
|
||||
pub struct PollCharactersResponse {
|
||||
pub characters: Vec<CharacterStatus>,
|
||||
|
||||
/// Present when there are more characters to fetch.
|
||||
/// Pass this value as `PollCharactersArgs::cursor` in the next call.
|
||||
pub next_cursor: Option<u64>,
|
||||
}
|
||||
|
||||
// --- Public types ---
|
||||
|
||||
/// Our resolved status for a character creation job.
|
||||
///
|
||||
/// This is NOT 1:1 with Kinovi's API. A character can have `taskStatus: "COMPLETED"`
|
||||
/// but `assetStatus: "Failed"`, which we treat as `Failed` on our end.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CharacterCreationStatus {
|
||||
/// The character was successfully created and the asset is active.
|
||||
Success,
|
||||
/// The character creation failed (either the task failed or the asset failed).
|
||||
Failed,
|
||||
/// The character is still being created (pending or processing).
|
||||
Pending,
|
||||
}
|
||||
|
||||
impl CharacterCreationStatus {
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(self, Self::Success | Self::Failed)
|
||||
}
|
||||
}
|
||||
|
||||
/// A single result image attached to a character.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CharacterResultImage {
|
||||
pub url: String,
|
||||
pub image_type: Option<String>,
|
||||
}
|
||||
|
||||
/// The status of one character.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CharacterStatus {
|
||||
/// Internal numeric ID.
|
||||
pub id: u64,
|
||||
|
||||
/// The character identifier (e.g. "char_1774752056469_2wlxoq").
|
||||
pub character_id: String,
|
||||
|
||||
/// The name of the character.
|
||||
pub name: String,
|
||||
|
||||
/// The description of the character.
|
||||
pub description: Option<String>,
|
||||
|
||||
/// The avatar URL (typically the uploaded reference image).
|
||||
pub avatar_url: Option<String>,
|
||||
|
||||
/// Result images generated during character creation.
|
||||
pub result_images: Vec<CharacterResultImage>,
|
||||
|
||||
/// Our resolved status (Success, Failed, or Pending).
|
||||
pub status: CharacterCreationStatus,
|
||||
|
||||
/// If the task failed, the reason from the API.
|
||||
pub fail_reason: Option<String>,
|
||||
|
||||
/// The asset ID, present when the character completed successfully.
|
||||
pub asset_id: Option<String>,
|
||||
|
||||
/// Raw task status from the Kinovi API (for debugging).
|
||||
pub raw_task_status: String,
|
||||
|
||||
/// Raw asset status from the Kinovi API (for debugging).
|
||||
pub raw_asset_status: Option<String>,
|
||||
|
||||
/// ISO 8601 timestamp of creation.
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// --- Implementation ---
|
||||
|
||||
pub async fn poll_characters(args: PollCharactersArgs<'_>) -> Result<PollCharactersResponse, Seedance2ProError> {
|
||||
let host = resolve_host(args.host_override.as_ref());
|
||||
let base_url = host.api_base_url();
|
||||
|
||||
let limit = args.limit.unwrap_or(50);
|
||||
|
||||
// The query param is URL-encoded JSON: {"0":{"json":{"limit":N}}} or {"0":{"json":{"limit":N,"cursor":C}}}
|
||||
let input = match args.cursor {
|
||||
Some(cursor) => format!(
|
||||
r#"{{"0":{{"json":{{"limit":{},"cursor":{}}}}}}}"#,
|
||||
limit, cursor
|
||||
),
|
||||
None => format!(
|
||||
r#"{{"0":{{"json":{{"limit":{}}}}}}}"#,
|
||||
limit
|
||||
),
|
||||
};
|
||||
|
||||
let encoded_input: String = url::form_urlencoded::byte_serialize(input.as_bytes()).collect();
|
||||
|
||||
let url = format!(
|
||||
"{}/api/trpc/character.getCharacters?batch=1&input={}",
|
||||
base_url,
|
||||
encoded_input,
|
||||
);
|
||||
|
||||
info!("Polling characters (limit={}, cursor={:?})...", limit, args.cursor);
|
||||
|
||||
let cookie = args.session.cookies.as_str();
|
||||
let referer = format!("{}/app/characters", base_url);
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox143)
|
||||
.build()
|
||||
.map_err(|err| Seedance2ProClientError::WreqClientError(err))?;
|
||||
|
||||
let response = client.get(&url)
|
||||
.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")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
|
||||
|
||||
info!("Poll characters 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 data = batch_response
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse(
|
||||
"Empty batch response array".to_string()
|
||||
))?
|
||||
.result
|
||||
.data
|
||||
.json;
|
||||
|
||||
let characters: Vec<CharacterStatus> = data.items.into_iter().map(|item| {
|
||||
let result_images = item.result_images
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|img| CharacterResultImage {
|
||||
url: img.url,
|
||||
image_type: img.image_type,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Derive our status from the raw Kinovi API fields.
|
||||
// Kinovi reports "COMPLETED" even when the character actually failed:
|
||||
// - assetStatus "Failed" => the asset generation failed
|
||||
// - assetStatus null (no assetId) => the task completed but produced no asset
|
||||
// Only taskStatus "COMPLETED" + assetStatus "Active" is a true success.
|
||||
let status = match item.task_status.as_str() {
|
||||
"FAILED" => CharacterCreationStatus::Failed,
|
||||
"COMPLETED" => match item.asset_status.as_deref() {
|
||||
Some("Active") => CharacterCreationStatus::Success,
|
||||
_ => CharacterCreationStatus::Failed, // "Failed", null, or any other value
|
||||
},
|
||||
_ => CharacterCreationStatus::Pending,
|
||||
};
|
||||
|
||||
CharacterStatus {
|
||||
id: item.id,
|
||||
character_id: item.character_id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
avatar_url: item.avatar_url,
|
||||
result_images,
|
||||
status,
|
||||
fail_reason: item.fail_reason,
|
||||
asset_id: item.asset_id,
|
||||
raw_task_status: item.task_status,
|
||||
raw_asset_status: item.asset_status,
|
||||
created_at: item.created_at,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Parse next_cursor: it's either a JSON number or null.
|
||||
let next_cursor = data.next_cursor
|
||||
.and_then(|v| v.as_u64());
|
||||
|
||||
info!("Polled {} character(s), next_cursor={:?}", characters.len(), next_cursor);
|
||||
|
||||
Ok(PollCharactersResponse { characters, next_cursor })
|
||||
}
|
||||
|
||||
#[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_poll_characters() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = poll_characters(PollCharactersArgs {
|
||||
session: &session,
|
||||
limit: None,
|
||||
cursor: None,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
println!("Characters returned: {}", result.characters.len());
|
||||
println!("Next cursor: {:?}", result.next_cursor);
|
||||
for ch in &result.characters {
|
||||
println!(
|
||||
" {} | {} | {:?} | asset_id={:?} | avatar={:?}",
|
||||
ch.character_id, ch.name, ch.status, ch.asset_id, ch.avatar_url,
|
||||
);
|
||||
}
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies
|
||||
async fn test_poll_characters_small_limit() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = poll_characters(PollCharactersArgs {
|
||||
session: &session,
|
||||
limit: Some(2),
|
||||
cursor: None,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
println!("Characters returned (limit=2): {}", result.characters.len());
|
||||
println!("Next cursor: {:?}", result.next_cursor);
|
||||
for ch in &result.characters {
|
||||
println!(
|
||||
" {} | {} | {:?} | asset_id={:?}",
|
||||
ch.character_id, ch.name, ch.status, ch.asset_id,
|
||||
);
|
||||
}
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies; exhausts all pages
|
||||
async fn test_poll_all_pages() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
|
||||
let mut cursor: Option<u64> = None;
|
||||
let mut page = 0usize;
|
||||
let mut total_characters = 0usize;
|
||||
|
||||
loop {
|
||||
page += 1;
|
||||
let result = poll_characters(PollCharactersArgs {
|
||||
session: &session,
|
||||
limit: Some(2),
|
||||
cursor,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
let page_count = result.characters.len();
|
||||
total_characters += page_count;
|
||||
|
||||
println!("Page {}: {} characters, next_cursor: {:?}", page, page_count, result.next_cursor);
|
||||
for ch in &result.characters {
|
||||
println!(" {} | {} | {:?} | asset_id={:?}", ch.character_id, ch.name, ch.status, ch.asset_id);
|
||||
}
|
||||
|
||||
cursor = result.next_cursor;
|
||||
if cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
println!("Total characters across {} pages: {}", page, total_characters);
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies; logs all character details
|
||||
async fn test_poll_all_characters_verbose() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let result = poll_characters(PollCharactersArgs {
|
||||
session: &session,
|
||||
limit: None,
|
||||
cursor: None,
|
||||
host_override: None,
|
||||
}).await?;
|
||||
|
||||
println!("=== All Characters ({} total) ===", result.characters.len());
|
||||
for ch in &result.characters {
|
||||
println!("Character: {} ({})", ch.name, ch.character_id);
|
||||
println!(" Description: {:?}", ch.description);
|
||||
println!(" Status: {:?}", ch.status);
|
||||
println!(" Avatar URL: {:?}", ch.avatar_url);
|
||||
println!(" Asset ID: {:?}", ch.asset_id);
|
||||
println!(" Raw Task Status: {}", ch.raw_task_status);
|
||||
println!(" Raw Asset Status: {:?}", ch.raw_asset_status);
|
||||
println!(" Fail Reason: {:?}", ch.fail_reason);
|
||||
println!(" Created At: {}", ch.created_at);
|
||||
println!(" Result Images:");
|
||||
for img in &ch.result_images {
|
||||
println!(" {} (type={:?})", img.url, img.image_type);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
// ======================== Response ========================
|
||||
|
||||
// The request is a GET with query params, no request body.
|
||||
|
||||
#[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: CharacterListJson,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct CharacterListJson {
|
||||
pub items: Vec<CharacterItemJson>,
|
||||
pub next_cursor: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct CharacterItemJson {
|
||||
pub id: u64,
|
||||
pub character_id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub result_images: Option<Vec<ResultImageJson>>,
|
||||
pub task_status: String,
|
||||
pub fail_reason: Option<String>,
|
||||
pub asset_id: Option<String>,
|
||||
pub asset_status: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(super) struct ResultImageJson {
|
||||
pub url: String,
|
||||
#[serde(rename = "type")]
|
||||
pub image_type: Option<String>,
|
||||
}
|
||||
Reference in New Issue
Block a user