diff --git a/src/cli.rs b/src/cli.rs index c46f589..1c37342 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,6 +2,7 @@ use clap::{Parser, Subcommand}; use crate::commands::asset::AssetArgs; use crate::commands::project::ProjectArgs; +use crate::commands::task::TaskArgs; use crate::commands::upload::UploadArgs; #[derive(Parser, Debug)] @@ -44,6 +45,7 @@ pub struct Cli { pub enum Command { Asset(AssetArgs), Project(ProjectArgs), + Task(TaskArgs), Upload(UploadArgs), } diff --git a/src/commands/api_config.rs b/src/commands/api_config.rs index efbb198..18e61e7 100644 --- a/src/commands/api_config.rs +++ b/src/commands/api_config.rs @@ -31,3 +31,22 @@ pub fn create_config() -> Configuration { cfg } +pub fn format_api_error(e: &tellers_api_client::apis::Error) -> String { + let mut message = format!("{}", e); + match e { + tellers_api_client::apis::Error::Reqwest(req_err) => { + if let Some(status) = req_err.status() { + message.push_str(&format!("; http_status: {}", status)); + } + } + tellers_api_client::apis::Error::ResponseError(resp) => { + message.push_str(&format!("; http_status: {}", resp.status)); + if !resp.content.is_empty() { + message.push_str(&format!("; response: {}", resp.content)); + } + } + _ => {} + } + message +} + diff --git a/src/commands/mod.rs b/src/commands/mod.rs index aa83642..d718de7 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,4 +2,5 @@ pub mod api_config; pub mod asset; pub mod prompt; pub mod project; +pub mod task; pub mod upload; diff --git a/src/commands/project/export.rs b/src/commands/project/export.rs index ef3ae26..c384527 100644 --- a/src/commands/project/export.rs +++ b/src/commands/project/export.rs @@ -1,7 +1,10 @@ +use std::time::Duration; + use clap::Args; use tellers_api_client::apis::accepts_api_key_api as api; -use crate::commands::api_config; +use crate::commands::{api_config, task}; +use crate::output; #[derive(Args, Debug)] pub struct ExportArgs { @@ -18,6 +21,10 @@ pub struct ExportArgs { #[arg(long, env = "TELLERS_AUTH_BEARER")] pub auth_bearer: Option, + + /// Poll GET /users/tasks/{task_id} until the export completes. + #[arg(long, default_value_t = false)] + pub wait: bool, } const ALLOWED_RENDITIONS: &[&str] = &["360p", "480p", "720p", "1080p", "1440p", "4k"]; @@ -66,27 +73,31 @@ pub fn run(args: ExportArgs) -> Result<(), String> { bearer_header.as_deref(), ) .await - .map_err(|e| { - let mut m = format!("export failed: {}", e); - match &e { - tellers_api_client::apis::Error::Reqwest(req_err) => { - if let Some(status) = req_err.status() { - m.push_str(&format!("; http_status: {}", status)); - } - } - tellers_api_client::apis::Error::ResponseError(resp) => { - m.push_str(&format!("; http_status: {}", resp.status)); - if !resp.content.is_empty() { - m.push_str(&format!("; response: {}", resp.content)); - } - } - _ => {} - } - m - })?; + .map_err(|e| api_config::format_api_error(&e))?; println!("task_id: {}", resp.task_id); println!("asset_id: {}", resp.asset_id); + + if args.wait { + output::info(format!( + "Waiting for export task {} to complete...", + resp.task_id + )); + let result = task::wait_for_user_task( + &cfg, + &resp.task_id, + &api_key, + bearer_header.as_deref(), + Duration::from_secs(2), + ) + .await?; + println!( + "{}", + serde_json::to_string_pretty(&result) + .map_err(|e| format!("failed to encode export result: {}", e))? + ); + } + Ok(()) }) } diff --git a/src/commands/task.rs b/src/commands/task.rs new file mode 100644 index 0000000..b672a59 --- /dev/null +++ b/src/commands/task.rs @@ -0,0 +1,264 @@ +use std::time::Duration; + +use clap::{Args, Subcommand}; +use serde::Deserialize; +use tellers_api_client::apis::accepts_api_key_api as api; +use tellers_api_client::apis::configuration::Configuration; +use tokio::time::sleep; + +use crate::commands::api_config; +use crate::output; + +#[derive(Args, Debug)] +pub struct TaskArgs { + #[command(subcommand)] + pub command: TaskCommand, +} + +#[derive(Subcommand, Debug)] +pub enum TaskCommand { + /// Fetch the current status of a user task. + Get(GetArgs), + /// Poll a user task until it completes or fails. + Wait(WaitArgs), + /// Cancel a running user task. + Cancel(CancelArgs), +} + +#[derive(Args, Debug)] +pub struct GetArgs { + pub task_id: String, + + #[arg(long, env = "TELLERS_API_KEY")] + pub api_key: Option, + + #[arg(long, env = "TELLERS_AUTH_BEARER")] + pub auth_bearer: Option, +} + +#[derive(Args, Debug)] +pub struct WaitArgs { + pub task_id: String, + + #[arg(long, default_value_t = 2)] + pub interval_secs: u64, + + #[arg(long, env = "TELLERS_API_KEY")] + pub api_key: Option, + + #[arg(long, env = "TELLERS_AUTH_BEARER")] + pub auth_bearer: Option, +} + +#[derive(Args, Debug)] +pub struct CancelArgs { + pub task_id: String, + + #[arg(long, env = "TELLERS_API_KEY")] + pub api_key: Option, + + #[arg(long, env = "TELLERS_AUTH_BEARER")] + pub auth_bearer: Option, +} + +#[derive(Debug, Clone)] +pub enum UserTaskStatus { + Pending { progress: Option }, + Complete { result: serde_json::Value }, + Failed { result: Option }, +} + +#[derive(Deserialize)] +struct RawUserTaskResponse { + status: String, + #[serde(default)] + progress: Option, + #[serde(default)] + result: Option, +} + +pub fn run(args: TaskArgs) -> Result<(), String> { + match args.command { + TaskCommand::Get(get_args) => run_get(get_args), + TaskCommand::Wait(wait_args) => run_wait(wait_args), + TaskCommand::Cancel(cancel_args) => run_cancel(cancel_args), + } +} + +fn run_get(args: GetArgs) -> Result<(), String> { + let cfg = api_config::create_config(); + let api_key = api_config::get_api_key(args.api_key)?; + let bearer = api_config::get_bearer_header(args.auth_bearer); + + tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to start runtime: {}", e))? + .block_on(async move { + let status = + fetch_user_task(&cfg, &args.task_id, &api_key, bearer.as_deref()).await?; + println!("{}", serde_json::to_string_pretty(&status_to_json(status)) + .map_err(|e| format!("failed to encode task status: {}", e))?); + Ok(()) + }) +} + +fn run_wait(args: WaitArgs) -> Result<(), String> { + let cfg = api_config::create_config(); + let api_key = api_config::get_api_key(args.api_key)?; + let bearer = api_config::get_bearer_header(args.auth_bearer); + + tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to start runtime: {}", e))? + .block_on(async move { + output::info(format!( + "Polling /users/tasks/{} every {}s...", + args.task_id, args.interval_secs + )); + let result = wait_for_user_task( + &cfg, + &args.task_id, + &api_key, + bearer.as_deref(), + Duration::from_secs(args.interval_secs), + ) + .await?; + println!("{}", serde_json::to_string_pretty(&result) + .map_err(|e| format!("failed to encode task result: {}", e))?); + Ok(()) + }) +} + +fn run_cancel(args: CancelArgs) -> Result<(), String> { + let cfg = api_config::create_config(); + let api_key = api_config::get_api_key(args.api_key)?; + let bearer = api_config::get_bearer_header(args.auth_bearer); + + tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to start runtime: {}", e))? + .block_on(async move { + let resp = api::cancel_user_task_users_tasks_task_id_delete( + &cfg, + &args.task_id, + Some(&api_key), + bearer.as_deref(), + ) + .await + .map_err(|e| api_config::format_api_error(&e))?; + + println!("task_id: {}", resp.task_id); + println!("previous_state: {:?}", resp.previous_state); + println!("description: {}", resp.description); + Ok(()) + }) +} + +pub async fn fetch_user_task( + cfg: &Configuration, + task_id: &str, + api_key: &str, + bearer: Option<&str>, +) -> Result { + let uri = format!( + "{}/users/tasks/{}", + cfg.base_path.trim_end_matches('/'), + urlencoding_encode(task_id) + ); + let mut req = cfg.client.request(reqwest::Method::GET, &uri); + if let Some(user_agent) = &cfg.user_agent { + req = req.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + req = req.header("x-api-key", api_key); + if let Some(bearer) = bearer { + req = req.header("authorization", bearer); + } + + let resp = req + .send() + .await + .map_err(|e| format!("failed to fetch task: {}", e))?; + let status_code = resp.status(); + let body = resp + .text() + .await + .map_err(|e| format!("failed to read task response: {}", e))?; + if !status_code.is_success() { + return Err(format!( + "failed to fetch task; http_status: {}; response: {}", + status_code, body + )); + } + + let raw: RawUserTaskResponse = serde_json::from_str(&body) + .map_err(|e| format!("failed to parse task response: {}; body: {}", e, body))?; + + parse_user_task_status(&raw) +} + +pub async fn wait_for_user_task( + cfg: &Configuration, + task_id: &str, + api_key: &str, + bearer: Option<&str>, + poll_interval: Duration, +) -> Result { + loop { + match fetch_user_task(cfg, task_id, api_key, bearer).await? { + UserTaskStatus::Pending { progress } => { + if let Some(progress) = progress { + output::info(format!("task progress: {:.0}%", progress_to_percent(progress))); + } + sleep(poll_interval).await; + } + UserTaskStatus::Complete { result } => return Ok(result), + UserTaskStatus::Failed { result } => { + let detail = result + .map(|v| v.to_string()) + .unwrap_or_else(|| "unknown error".to_string()); + return Err(format!("task failed: {}", detail)); + } + } + } +} + +fn parse_user_task_status(raw: &RawUserTaskResponse) -> Result { + match raw.status.as_str() { + "PENDING" => Ok(UserTaskStatus::Pending { + progress: raw.progress, + }), + "COMPLETE" => Ok(UserTaskStatus::Complete { + result: raw.result.clone().unwrap_or(serde_json::Value::Null), + }), + "FAILED" => Ok(UserTaskStatus::Failed { + result: raw.result.clone(), + }), + other => Err(format!("unknown task status: {}", other)), + } +} + +fn status_to_json(status: UserTaskStatus) -> serde_json::Value { + match status { + UserTaskStatus::Pending { progress } => serde_json::json!({ + "status": "PENDING", + "progress": progress, + }), + UserTaskStatus::Complete { result } => serde_json::json!({ + "status": "COMPLETE", + "result": result, + }), + UserTaskStatus::Failed { result } => serde_json::json!({ + "status": "FAILED", + "result": result, + }), + } +} + +fn progress_to_percent(progress: f64) -> f64 { + if (0.0..=1.0).contains(&progress) { + progress * 100.0 + } else { + progress.clamp(0.0, 100.0) + } +} + +fn urlencoding_encode(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} diff --git a/src/main.rs b/src/main.rs index b82ae66..eb2f93c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,12 @@ fn main() { } } } + Some(cli::Command::Task(task_args)) => { + if let Err(error) = commands::task::run(task_args) { + eprintln!("error: {}", error); + std::process::exit(1); + } + } Some(cli::Command::Upload(upload_args)) => { let suppress_plain_error = matches!( &upload_args.command, diff --git a/src/tellers_api/openapi.tellers_public_api.yaml b/src/tellers_api/openapi.tellers_public_api.yaml index e243a38..932c773 100644 --- a/src/tellers_api/openapi.tellers_public_api.yaml +++ b/src/tellers_api/openapi.tellers_public_api.yaml @@ -415,6 +415,93 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /users/tasks/{task_id}: + get: + tags: + - accepts-api-key + summary: Get User Task + operationId: get_user_task_users_tasks__task_id__get + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + responses: + '200': + description: Successful Response + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/PendingTaskResponse' + - $ref: '#/components/schemas/CompleteTaskResponse' + - $ref: '#/components/schemas/FailedTaskResponse' + title: Response Get User Task Users Tasks Task Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - accepts-api-key + summary: Cancel User Task + operationId: cancel_user_task_users_tasks__task_id__delete + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CancelTaskResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /users/tasks: get: tags: @@ -898,17 +985,18 @@ paths: description: 'Start async export of project to MP4 at one or more resolutions. Creates the export asset - in the API, then enqueues a task that generates each rendition. The export - appears as a + in the API, then enqueues a task that browser-encodes the highest requested + resolution. - new video asset in the project''s export folder (project name + date + .mp4), - with multiple + Smaller renditions are enqueued as downscaling_task jobs (not chained). The + export - renditions (e.g. 480p, 720p, 1080p, 4k) attached to that asset. + appears as a new video asset in the project''s export folder (project name + + date + .mp4). - Returns task_id and asset_id. Poll GET /task/{task_id} for completion; result - includes asset_id.' + Returns task_id and asset_id. Poll GET /users/tasks/{task_id} for completion; + result includes asset_id.' operationId: export_project_project__project_id__export_post parameters: - name: project_id @@ -1626,6 +1714,95 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /task/{task_id}: + get: + tags: + - accepts-api-key + summary: Get Task + operationId: get_task_task__task_id__get + deprecated: true + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + responses: + '200': + description: Successful Response + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/PendingTaskResponse' + - $ref: '#/components/schemas/CompleteTaskResponse' + - $ref: '#/components/schemas/FailedTaskResponse' + title: Response Get Task Task Task Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - accepts-api-key + summary: Delete Task + operationId: delete_task_task__task_id__delete + deprecated: true + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CancelTaskResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' components: schemas: AgentMessageRequest: @@ -2085,6 +2262,11 @@ components: maintenance_mode: type: boolean title: Maintenance Mode + social_publishing_platforms: + items: + $ref: '#/components/schemas/SocialPublishingPlatformSetting' + type: array + title: Social Publishing Platforms available_agent_tools: items: additionalProperties: true @@ -2104,6 +2286,7 @@ components: type: object required: - maintenance_mode + - social_publishing_platforms - available_agent_tools - available_llm_models - presets @@ -2478,6 +2661,22 @@ components: required: - plans title: BillingCatalog + CancelTaskResponse: + properties: + task_id: + type: string + title: Task Id + previous_state: + $ref: '#/components/schemas/TaskStatus' + description: + type: string + title: Description + type: object + required: + - task_id + - previous_state + - description + title: CancelTaskResponse CatalogFeature: properties: text: @@ -2819,6 +3018,23 @@ components: - context - created_at title: ChatMessageUserResponse + CompleteTaskResponse: + properties: + status: + type: string + const: COMPLETE + title: Status + default: COMPLETE + result: + anyOf: + - type: object + additionalProperties: true + - type: 'null' + title: Result + type: object + required: + - result + title: CompleteTaskResponse CouponCheckResponse: properties: coupon_code: @@ -2903,6 +3119,21 @@ components: title: ExportProjectResponse description: 'Response for POST /project/{project_id}/export: task_id and the new export asset_id.' + FailedTaskResponse: + properties: + status: + type: string + const: FAILED + title: Status + default: FAILED + result: + anyOf: + - type: object + additionalProperties: true + - type: 'null' + title: Result + type: object + title: FailedTaskResponse FileReference: properties: file_name: @@ -3007,6 +3238,26 @@ components: - multipart_upload_id - parts title: MultipartCompleteRequest + PendingTaskResponse: + properties: + status: + type: string + const: PENDING + title: Status + default: PENDING + progress: + anyOf: + - type: number + - type: 'null' + title: Progress + result: + anyOf: + - type: object + additionalProperties: true + - type: 'null' + title: Result + type: object + title: PendingTaskResponse PlanLineItem: properties: price_id: @@ -3102,6 +3353,25 @@ components: required: - task_id title: RequestTaskResponse + SocialPublishingPlatformSetting: + properties: + id: + type: string + title: Id + label: + type: string + title: Label + supported_media_types: + items: + type: string + type: array + title: Supported Media Types + type: object + required: + - id + - label + - supported_media_types + title: SocialPublishingPlatformSetting SourceFileInfo: properties: sourceName: @@ -3262,6 +3532,18 @@ components: - ended_at - error_message title: TaskResponse + TaskStatus: + type: string + enum: + - FAILURE + - PENDING + - RECEIVED + - RETRY + - REVOKED + - STARTED + - SUCCESS + - PROGRESS + title: TaskStatus TellersAvailableOptions: properties: verbosity: