> ## Documentation Index
> Fetch the complete documentation index at: https://docs.api.rask.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# SDKs for Rask API v3

> Official Rask API v3 clients for TypeScript, Python, Go, Java and PHP: where the code is, how to install each, a whole program in each that dubs a video, the error classes to catch, and what every SDK does for you.

The SDKs call the same 82 operations as the REST API, under the names the [operation table](https://docs.api.rask.ai/v3/concepts/operation-table.md)
uses. Each one is built from the contract and tested against one shared list of scenarios, so they behave alike. They are
alpha, like the API, and open source under the MIT license.

| Language | Source | Install |
| - | - | - |
| TypeScript, on Node 22+, Bun and Deno | [braskai/rask-sdk-typescript](https://github.com/braskai/rask-sdk-typescript) | From npm: [@braskai/api](https://www.npmjs.com/package/@braskai/api) |
| Python 3.11+ | [braskai/rask-sdk-python](https://github.com/braskai/rask-sdk-python) | From PyPI: [rask-sdk](https://pypi.org/project/rask-sdk/) |
| Go 1.25+ | [braskai/rask-sdk-go](https://github.com/braskai/rask-sdk-go) | With `go get`: [github.com/braskai/rask-sdk-go/v3](https://pkg.go.dev/github.com/braskai/rask-sdk-go/v3) |
| Java 11+ | [braskai/rask-sdk-java](https://github.com/braskai/rask-sdk-java) | From Maven Central: [ai.rask:rask-sdk](https://central.sonatype.com/artifact/ai.rask/rask-sdk) |
| PHP 8.2+ | [braskai/rask-sdk-php](https://github.com/braskai/rask-sdk-php) | From Packagist: [braskai/rask-sdk](https://packagist.org/packages/braskai/rask-sdk) |

Each repository's README covers the rest of its SDK: client options, retries, lists and error classes.

## Install

```bash TypeScript theme={null}
npm install @braskai/api
```

```bash Python theme={null}
pip install rask-sdk
```

```bash Go theme={null}
go get github.com/braskai/rask-sdk-go/v3
```

```xml Java theme={null}
<dependency>
  <groupId>ai.rask</groupId>
  <artifactId>rask-sdk</artifactId>
  <version>3.0.0-alpha.0</version>
</dependency>
```

```bash PHP theme={null}
composer require "braskai/rask-sdk:^3.0@alpha"
```

* **TypeScript.** The same package runs on Bun and Deno: `bun add @braskai/api` or `deno add npm:@braskai/api`.
* **Python.** With uv: `uv add rask-sdk`.
* **Java.** With Gradle: `implementation("ai.rask:rask-sdk:3.0.0-alpha.0")`. Maven and Gradle take an exact version, and
  [Maven Central](https://central.sonatype.com/artifact/ai.rask/rask-sdk) shows the newest one.
* **PHP.** Composer installs a pre-release only when the version constraint allows it, so the command names the alpha
  explicitly. Without that, Composer answers that it finds no stable version.

Then set the key where the SDK reads it, the same variable the cURL examples use:

```bash theme={null}
export RASK_API_KEY="paste your key here"
```

## Make a client

Every example in these pages has a tab for each language, and cURL. A tab shows the call and nothing around it: it takes a
client made like this, which reads the key from `RASK_API_KEY`.

```typescript TypeScript theme={null}
import { Rask } from '@braskai/api';

const rask = new Rask();
```

```python Python theme={null}
from rask_sdk import Rask

client = Rask()
```

```go Go theme={null}
import (
	"context"

	"github.com/braskai/rask-sdk-go/v3"
	"github.com/braskai/rask-sdk-go/v3/api"
)

ctx := context.Background()
client, err := rask.New()
```

```java Java theme={null}
import ai.rask.sdk.Rask;

Rask rask = Rask.builder().build();
```

```php PHP theme={null}
use Rask\Sdk\Rask;

$rask = new Rask();
```

The methods are named after the operations and grouped as the reference is, so [getMe](https://docs.api.rask.ai/operations/getMe.md) is under account
and [createProject](https://docs.api.rask.ai/operations/createProject.md) is under projects. Each language spells the names its own way. A Java example
also needs the request and response classes it names, imported from the SDK's model and params packages.

## Upload, wait and handle errors

The tabs on the other pages show one call each. Three things take more than one call over plain HTTP, and every SDK does
them for you: uploading a file, waiting for a job, and telling one error from another. Each tab here is a whole program
that does all three: it uploads a video, dubs it into Spanish and downloads the result.

```typescript TypeScript theme={null}
// Upload a video, dub it into Spanish and download the result: node dub.ts talk.mp4
// What it has done is saved in talk.mp4.dub.json. If it stops, run it again: it sends the same keys, and the API replays
// what already ran, so no project or dub is created or charged twice. Delete the file to dub the video anew.
import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import {
  ConflictError,
  InsufficientBalanceError,
  JobCanceledError,
  JobFailedError,
  Rask,
  RaskConnectionError,
  RaskError,
  WaitTimeoutError,
} from '@braskai/api';

const path = process.argv[2] ?? 'talk.mp4';
const statePath = `${path}.dub.json`;
const state: Record<string, string | number> = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : {};

// The value saved under this name, or a new one, saved before the program goes on.
async function once<T extends string | number>(name: string, make: () => T | Promise<T>): Promise<T> {
  if (!(name in state)) {
    state[name] = await make();
    writeFileSync(statePath, JSON.stringify(state, null, 2));
  }
  return state[name] as T;
}

function stop(message: string): never {
  console.error(message);
  process.exit(1);
}

try {
  const rask = new Rask(); // The key from RASK_API_KEY, and the base URL from RASK_BASE_URL when it is set.

  const mediaId = await once('media_id', async () => {
    // One call uploads the file: createUpload, the PUT to storage and completeUpload. It answers with the import job.
    const { data: importJob } = await rask.uploads.uploadFile(path);
    // One call waits for a job, with the API's long-poll. A job that failed is thrown, not returned.
    const { data: imported } = await rask.jobs.wait(importJob.id);
    if (imported.result?.type !== 'media_import') stop(`Job ${imported.id} has no media.`);
    return imported.result.media_id;
  });
  const order = { media_id: mediaId, target_language: 'es-es' };

  // The minutes shown once are the most this program spends: they are the cap of every paid request below.
  const maxMinutes = await once('max_minutes', async () => {
    const { data: estimate } = await rask.projects.estimateProject({ body: order });
    if (!estimate.eligible) stop(`Cannot start: ${estimate.reasons.map((reason) => reason.code).join(', ')}`);
    console.log(`The dub costs ${estimate.total_minutes} minutes.`);
    return estimate.total_minutes;
  });

  // The SDK makes an Idempotency-Key by itself, but a new one on every run. This key is saved before it is sent.
  const { data: created } = await rask.projects.createProject({
    body: { ...order, expected_cost_minutes: maxMinutes },
    idempotencyKey: await once('create_key', () => randomUUID()),
  });
  const { data: processed } = await rask.jobs.wait(created.id);
  if (processed.result?.type !== 'project_processing') stop(`Job ${processed.id} has no project.`);
  const projectId = processed.result.project_id;

  // That job ends when translation ends. The dub is a job of its own.
  const autoDub = processed.result.auto_dub;
  if (autoDub.state === 'dispatched' && autoDub.dub_job_id) {
    await rask.jobs.wait(autoDub.dub_job_id);
  } else {
    // The dub was skipped (skipped_reason says why) or not requested, and nothing was charged. Dub within the same cap.
    const { data: estimate } = await rask.dubbing.estimateDub({ path: { project_id: projectId }, body: {} });
    if (estimate.total_minutes > maxMinutes) {
      stop(
        `The dub now costs ${estimate.total_minutes} minutes, more than the ${maxMinutes} you accepted, and nothing was ` +
          `charged. Project ${projectId} keeps its translation: dub it once you accept the new price.`,
      );
    }
    const { data: dub } = await rask.dubbing.createDub({
      path: { project_id: projectId },
      body: { expected_cost_minutes: maxMinutes },
      idempotencyKey: await once('dub_key', () => randomUUID()),
    });
    await rask.jobs.wait(dub.id);
  }

  // The link is presigned: the download takes no key, and it works for 1 hour.
  const { data: outputs } = await rask.exports.listProjectOutputs({ path: { project_id: projectId } });
  const video = outputs.data.find((output) => output.kind === 'dubbed_video' && output.url);
  if (!video?.url) stop(`Project ${projectId} has no dubbed video to download.`);
  const download = await fetch(video.url);
  if (!download.ok) stop(`The download answered ${download.status}. Run this again for a fresh link.`);
  await writeFile('dubbed.mp4', new Uint8Array(await download.arrayBuffer()));
  console.log('Saved dubbed.mp4');
} catch (error) {
  // Check the subclasses before RaskError, the base of them all.
  if (error instanceof InsufficientBalanceError) {
    // creditType says which balance: minutes, or the project credits that createProject takes.
    stop(`${error.detail} Add to the balance in the web app, then run this again.`);
  } else if (error instanceof ConflictError && error.code === 'cost_changed') {
    stop(
      `The price is now ${error.problem?.current_cost_minutes} minutes, more than the ${error.problem?.expected_cost_minutes} ` +
        `you accepted, and nothing was charged. Delete ${statePath} to start again at the new price.`,
    );
  } else if (error instanceof JobFailedError) {
    stop(`Job ${error.job.id} failed: ${error.code}: ${error.detail}`);
  } else if (error instanceof JobCanceledError) {
    stop(`Job ${error.job.id} was canceled.`);
  } else if (error instanceof WaitTimeoutError) {
    stop(`Job ${error.jobId} is still running after ${error.timeoutSeconds} s. Run this again to keep waiting.`);
  } else if (error instanceof RaskConnectionError) {
    stop(`No answer from the API: ${error.message}. Run this again: it sends the same keys.`);
  } else if (error instanceof RaskError) {
    stop(error.message);
  }
  throw error;
}
```

```python Python theme={null}
"""Upload a video, dub it into Spanish and download the result: python dub.py talk.mp4

What it has done is saved in talk.mp4.dub.json. If it stops, run it again: it sends the same keys, and the API replays what
already ran, so no project or dub is created or charged twice. Delete the file to dub the video anew.
"""

import json
import sys
import uuid
from collections.abc import Callable
from pathlib import Path
from typing import TypeVar, cast

import httpx
from rask_sdk import (
    ConflictError,
    InsufficientBalanceError,
    JobCanceledError,
    JobFailedError,
    Rask,
    RaskConnectionError,
    RaskError,
    WaitTimeoutError,
)
from rask_sdk.models import MediaImportJobResult, ProjectProcessingJobResult

path = sys.argv[1] if len(sys.argv) > 1 else "talk.mp4"
state_file = Path(path + ".dub.json")
state: dict[str, str | int] = json.loads(state_file.read_text()) if state_file.exists() else {}
Value = TypeVar("Value", str, int)


def once(name: str, make: Callable[[], Value]) -> Value:
    """The value saved under this name, or a new one, saved before the program goes on."""
    if name not in state:
        state[name] = make()
        state_file.write_text(json.dumps(state, indent=2))
    return cast(Value, state[name])


def upload(client: Rask) -> str:
    # One call uploads the file: createUpload, the PUT to storage and completeUpload. It answers with the import job.
    import_job = client.uploads.upload_file(path)
    # One call waits for a job, with the API's long-poll. A job that failed is raised, not returned.
    imported = client.jobs.wait(import_job.id)
    if not isinstance(imported.result, MediaImportJobResult):
        sys.exit(f"Job {imported.id} has no media.")
    return imported.result.media_id


def accept_price(client: Rask, media_id: str) -> int:
    estimate = client.projects.estimate_project(media_id=media_id, target_language="es-es")
    if not estimate.eligible:
        sys.exit(f"Cannot start: {', '.join(reason.code for reason in estimate.reasons)}")
    print(f"The dub costs {estimate.total_minutes} minutes.")
    return estimate.total_minutes


def dub(client: Rask) -> None:
    media_id = once("media_id", lambda: upload(client))
    # The minutes shown once are the most this program spends: they are the cap of every paid request below.
    max_minutes = once("max_minutes", lambda: accept_price(client, media_id))

    # The SDK makes an Idempotency-Key by itself, but a new one on every run. This key is saved before it is sent.
    created = client.projects.create_project(
        media_id=media_id,
        target_language="es-es",
        expected_cost_minutes=max_minutes,
        idempotency_key=once("create_key", lambda: str(uuid.uuid4())),
    )
    processed = client.jobs.wait(created.id)
    if not isinstance(processed.result, ProjectProcessingJobResult):
        sys.exit(f"Job {processed.id} has no project.")
    project_id = processed.result.project_id

    # That job ends when translation ends. The dub is a job of its own.
    auto_dub = processed.result.auto_dub
    if auto_dub.state == "dispatched" and auto_dub.dub_job_id:
        client.jobs.wait(auto_dub.dub_job_id)
    else:
        # The dub was skipped (skipped_reason says why) or not requested, and nothing was charged. Dub within the same cap.
        estimate = client.dubbing.estimate_dub(project_id)
        if estimate.total_minutes > max_minutes:
            sys.exit(
                f"The dub now costs {estimate.total_minutes} minutes, more than the {max_minutes} you accepted, and nothing "
                f"was charged. Project {project_id} keeps its translation: dub it once you accept the new price."
            )
        dub_job = client.dubbing.create_dub(
            project_id, expected_cost_minutes=max_minutes, idempotency_key=once("dub_key", lambda: str(uuid.uuid4()))
        )
        client.jobs.wait(dub_job.id)

    # The link is presigned: the download takes no key, and it works for 1 hour.
    outputs = client.exports.list_project_outputs(project_id).data
    url = next((output.url for output in outputs if output.kind == "dubbed_video" and output.url), None)
    if url is None:
        sys.exit(f"Project {project_id} has no dubbed video to download.")
    with httpx.stream("GET", url, timeout=60) as response:
        if response.status_code != 200:
            sys.exit(f"The download answered {response.status_code}. Run this again for a fresh link.")
        with open("dubbed.mp4", "wb") as file:
            for chunk in response.iter_bytes():
                file.write(chunk)
    print("Saved dubbed.mp4")


try:
    dub(Rask())  # The key from RASK_API_KEY, and the base URL from RASK_BASE_URL when it is set.
except RaskError as error:
    # Check the subclasses before RaskError, the base of them all.
    if isinstance(error, InsufficientBalanceError):
        # credit_type says which balance: minutes, or the project credits that create_project takes.
        sys.exit(f"{error.detail} Add to the balance in the web app, then run this again.")
    if isinstance(error, ConflictError) and error.code == "cost_changed":
        sys.exit(
            f"The price is now {error.current_cost_minutes} minutes, more than the {error.expected_cost_minutes} you "
            f"accepted, and nothing was charged. Delete {state_file} to start again at the new price."
        )
    if isinstance(error, JobFailedError):
        sys.exit(f"Job {error.job.id} failed: {error.code}: {error.detail}")
    if isinstance(error, JobCanceledError):
        sys.exit(f"Job {error.job.id} was canceled.")
    if isinstance(error, WaitTimeoutError):
        sys.exit(f"Job {error.job_id} is still running. Run this again to keep waiting.")
    if isinstance(error, RaskConnectionError):
        sys.exit(f"No answer from the API: {error}. Run this again: it sends the same keys.")
    sys.exit(str(error))
```

```go Go theme={null}
// Command dub uploads a video, dubs it into Spanish and downloads the result: go run . talk.mp4
//
// What it has done is saved in talk.mp4.dub.json. If it stops, run it again: it sends the same keys, and the API replays
// what already ran, so no project or dub is created or charged twice. Delete the file to dub the video anew.
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"

	"github.com/braskai/rask-sdk-go/v3"
	"github.com/braskai/rask-sdk-go/v3/api"
	"github.com/google/uuid"
)

func main() {
	path := "talk.mp4"
	if len(os.Args) > 1 {
		path = os.Args[1]
	}
	client, err := rask.New() // The key from RASK_API_KEY, and the base URL from RASK_BASE_URL when it is set.
	if err == nil {
		err = dub(context.Background(), client, path)
	}
	if err != nil {
		log.Fatal(explain(err, path))
	}
	fmt.Println("Saved dubbed.mp4")
}

func dub(ctx context.Context, client *rask.Client, path string) error {
	saved, err := load(path + ".dub.json")
	if err != nil {
		return err
	}
	mediaID, err := saved.once("media_id", func() (string, error) {
		// One call uploads the file: createUpload, the PUT to storage and completeUpload. It answers with the import job.
		importJob, err := client.Uploads.UploadFile(ctx, rask.UploadFileParams{Path: path})
		if err != nil {
			return "", err
		}
		// One call waits for a job, with the API's long-poll. A job that failed is an error, not a result.
		imported, err := client.Jobs.Wait(ctx, importJob.Data.Id)
		if err != nil {
			return "", err
		}
		result, err := imported.Data.Result.Get()
		if err != nil {
			return "", fmt.Errorf("job %s has no media", imported.Data.Id)
		}
		media, err := result.AsMediaImportJobResult()
		if err != nil {
			return "", err
		}
		return media.MediaId, nil
	})
	if err != nil {
		return err
	}

	// The minutes shown once are the most this program spends: they are the cap of every paid request below.
	accepted, err := saved.once("max_minutes", func() (string, error) {
		estimate, err := client.Projects.EstimateProject(ctx, api.EstimateProjectRequest{MediaId: mediaID, TargetLanguage: "es-es"})
		if err != nil {
			return "", err
		}
		if !estimate.Data.Eligible {
			var codes []string
			for _, reason := range estimate.Data.Reasons {
				codes = append(codes, reason.Code)
			}
			return "", fmt.Errorf("cannot start: %s", strings.Join(codes, ", "))
		}
		fmt.Printf("The dub costs %d minutes.\n", estimate.Data.TotalMinutes)
		return strconv.Itoa(int(estimate.Data.TotalMinutes)), nil
	})
	if err != nil {
		return err
	}
	maxMinutes, err := strconv.Atoi(accepted)
	if err != nil {
		return err
	}

	// The SDK makes an Idempotency-Key by itself, but a new one on every run. This key is saved before it is sent.
	createKey, err := saved.once("create_key", func() (string, error) { return uuid.NewString(), nil })
	if err != nil {
		return err
	}
	created, err := client.Projects.CreateProject(ctx, api.CreateProjectRequest{
		MediaId:             mediaID,
		TargetLanguage:      "es-es",
		ExpectedCostMinutes: rask.Ptr(int32(maxMinutes)),
	}, rask.WithIdempotencyKey(createKey))
	if err != nil {
		return err
	}
	processed, err := client.Jobs.Wait(ctx, created.Data.Id)
	if err != nil {
		return err
	}
	result, err := processed.Data.Result.Get()
	if err != nil {
		return fmt.Errorf("job %s has no project", processed.Data.Id)
	}
	processing, err := result.AsProjectProcessingJobResult()
	if err != nil {
		return err
	}
	projectID := processing.ProjectId

	// That job ends when translation ends. The dub is a job of its own.
	dubJobID, err := processing.AutoDub.DubJobId.Get()
	if processing.AutoDub.State == api.AutoDubResultStateDispatched && err == nil {
		if _, err := client.Jobs.Wait(ctx, dubJobID); err != nil {
			return err
		}
	} else {
		// The dub was skipped (skipped_reason says why) or not requested, and nothing was charged. Dub within the same cap.
		estimate, err := client.Dubbing.EstimateDub(ctx, projectID, nil)
		if err != nil {
			return err
		}
		if int(estimate.Data.TotalMinutes) > maxMinutes {
			return fmt.Errorf("the dub now costs %d minutes, more than the %d you accepted, and nothing was charged. "+
				"Project %s keeps its translation: dub it once you accept the new price", estimate.Data.TotalMinutes, maxMinutes, projectID)
		}
		dubKey, err := saved.once("dub_key", func() (string, error) { return uuid.NewString(), nil })
		if err != nil {
			return err
		}
		dubJob, err := client.Dubbing.CreateDub(ctx, projectID, api.CreateDubRequest{
			ExpectedCostMinutes: int32(maxMinutes),
		}, rask.WithIdempotencyKey(dubKey))
		if err != nil {
			return err
		}
		if _, err := client.Jobs.Wait(ctx, dubJob.Data.Id); err != nil {
			return err
		}
	}

	// The link is presigned: the download takes no key, and it works for 1 hour.
	outputs, err := client.Exports.ListProjectOutputs(ctx, projectID)
	if err != nil {
		return err
	}
	for _, output := range outputs.Data.Data {
		if url, err := output.Url.Get(); err == nil && output.Kind == api.OutputKindDubbedVideo {
			return download(ctx, url, "dubbed.mp4")
		}
	}
	return fmt.Errorf("project %s has no dubbed video to download", projectID)
}

// explain says what went wrong. Each API error is a *rask.RaskError too, so the specific types are matched first.
func explain(err error, path string) string {
	var noBalance *rask.InsufficientBalanceError
	var conflict *rask.ConflictError
	var failed *rask.JobFailedError
	var canceled *rask.JobCanceledError
	var timedOut *rask.WaitTimeoutError
	var noAnswer *rask.ConnectionError
	switch {
	case errors.As(err, &noBalance):
		// CreditType says which balance: minutes, or the project credits that CreateProject takes.
		return noBalance.Detail + " Add to the balance in the web app, then run this again."
	case errors.As(err, &conflict) && conflict.Code == "cost_changed":
		return fmt.Sprintf("The price is now %s minutes, more than the %s you accepted, and nothing was charged. "+
			"Delete %s.dub.json to start again at the new price.",
			minutes(conflict.Problem.CurrentCostMinutes), minutes(conflict.Problem.ExpectedCostMinutes), path)
	case errors.As(err, &failed):
		return fmt.Sprintf("Job %s failed: %s: %s", failed.JobID, failed.Code, failed.Detail)
	case errors.As(err, &canceled):
		return fmt.Sprintf("Job %s was canceled.", canceled.JobID)
	case errors.As(err, &timedOut):
		return fmt.Sprintf("Job %s is still running after %s. Run this again to keep waiting.", timedOut.JobID, timedOut.Timeout)
	case errors.As(err, &noAnswer):
		return fmt.Sprintf("No answer from the API: %v. Run this again: it sends the same keys.", noAnswer)
	}
	// Any other *rask.RaskError, and everything else, says what it is.
	return err.Error()
}

func minutes(value *int32) string {
	if value == nil {
		return "?"
	}
	return strconv.Itoa(int(*value))
}

func download(ctx context.Context, url, path string) error {
	request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}
	response, err := http.DefaultClient.Do(request)
	if err != nil {
		return err
	}
	defer response.Body.Close()
	if response.StatusCode != http.StatusOK {
		return fmt.Errorf("the download answered %d. Run this again for a fresh link", response.StatusCode)
	}
	file, err := os.Create(path)
	if err != nil {
		return err
	}
	if _, err := io.Copy(file, response.Body); err != nil {
		file.Close()
		return err
	}
	return file.Close()
}

// state is what the program has done, saved in a file so that a second run goes on from where the first stopped.
type state struct {
	path   string
	values map[string]string
}

func load(path string) (*state, error) {
	saved := &state{path: path, values: map[string]string{}}
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return saved, nil
	}
	if err != nil {
		return nil, err
	}
	return saved, json.Unmarshal(data, &saved.values)
}

// once returns the value saved under this name, or a new one, saved before the program goes on.
func (s *state) once(name string, create func() (string, error)) (string, error) {
	if value, ok := s.values[name]; ok {
		return value, nil
	}
	value, err := create()
	if err != nil {
		return "", err
	}
	s.values[name] = value
	data, err := json.MarshalIndent(s.values, "", "  ")
	if err != nil {
		return "", err
	}
	return value, os.WriteFile(s.path, data, 0o600)
}
```

```java Java theme={null}
import ai.rask.sdk.Rask;
import ai.rask.sdk.RequestOptions;
import ai.rask.sdk.errors.ConflictError;
import ai.rask.sdk.errors.InsufficientBalanceError;
import ai.rask.sdk.errors.JobCanceledError;
import ai.rask.sdk.errors.JobFailedError;
import ai.rask.sdk.errors.RaskConnectionError;
import ai.rask.sdk.errors.RaskError;
import ai.rask.sdk.errors.WaitTimeoutError;
import ai.rask.sdk.model.AutoDubResultState;
import ai.rask.sdk.model.CreateDubRequest;
import ai.rask.sdk.model.CreateProjectRequest;
import ai.rask.sdk.model.Estimate;
import ai.rask.sdk.model.EstimateProjectRequest;
import ai.rask.sdk.model.EstimateReason;
import ai.rask.sdk.model.Job;
import ai.rask.sdk.model.MediaImportJobResult;
import ai.rask.sdk.model.Output;
import ai.rask.sdk.model.OutputKind;
import ai.rask.sdk.model.ProjectProcessingAutoDubResult;
import ai.rask.sdk.model.ProjectProcessingJobResult;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.UncheckedIOException;
import java.io.Writer;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Properties;
import java.util.UUID;
import java.util.function.Supplier;
import java.util.stream.Collectors;

/**
 * Uploads a video, dubs it into Spanish and downloads the result: java -cp rask-sdk-3.0.0-alpha.0.jar Dub.java talk.mp4
 *
 * <p>What it has done is saved in talk.mp4.dub.properties. If it stops, run it again: it sends the same keys, and the API
 * replays what already ran, so no project or dub is created or charged twice. Delete the file to dub the video anew.
 */
public final class Dub {
    private static Path statePath;
    private static final Properties STATE = new Properties();

    public static void main(String[] args) throws IOException, InterruptedException {
        String path = args.length > 0 ? args[0] : "talk.mp4";
        statePath = Path.of(path + ".dub.properties");
        if (Files.exists(statePath)) {
            try (Reader reader = Files.newBufferedReader(statePath)) {
                STATE.load(reader);
            }
        }
        try {
            dub(Rask.builder().build(), path); // The key from RASK_API_KEY, and the base URL from RASK_BASE_URL when it is set.
        } catch (InsufficientBalanceError error) {
            // creditType() says which balance: minutes, or the project credits that createProject takes.
            stop(error.detail() + " Add to the balance in the web app, then run this again.");
        } catch (ConflictError error) {
            if ("cost_changed".equals(error.code())) {
                stop("The price is now " + error.problem().currentCostMinutes() + " minutes, more than the "
                        + error.problem().expectedCostMinutes() + " you accepted, and nothing was charged. Delete "
                        + statePath + " to start again at the new price.");
            }
            stop(error.getMessage());
        } catch (JobFailedError error) {
            stop("Job " + error.job().id() + " failed: " + error.code() + ": " + error.detail());
        } catch (JobCanceledError error) {
            stop("Job " + error.job().id() + " was canceled.");
        } catch (WaitTimeoutError error) {
            stop("Job " + error.jobId() + " is still running. Run this again to keep waiting.");
        } catch (RaskConnectionError error) {
            stop("No answer from the API: " + error.getMessage() + ". Run this again: it sends the same keys.");
        } catch (RaskError error) {
            // The base of them all, so it comes last.
            stop(error.getMessage());
        }
    }

    private static void dub(Rask rask, String path) throws IOException, InterruptedException {
        String mediaId = once("media_id", () -> {
            // One call uploads the file: createUpload, the PUT to storage and completeUpload. It answers with the import job.
            Job importJob = rask.uploads().uploadFile(Path.of(path)).data();
            // One call waits for a job, with the API's long-poll. A job that failed is thrown, not returned.
            Job imported = rask.jobs().wait(importJob.id()).data();
            if (!(imported.result() instanceof MediaImportJobResult)) {
                stop("Job " + imported.id() + " has no media.");
            }
            return ((MediaImportJobResult) imported.result()).mediaId();
        });

        // The minutes shown once are the most this program spends: they are the cap of every paid request below.
        int maxMinutes = Integer.parseInt(once("max_minutes", () -> {
            Estimate estimate = rask.projects().estimateProject(EstimateProjectRequest.builder()
                    .mediaId(mediaId)
                    .targetLanguage("es-es")
                    .build()).data();
            if (!estimate.eligible()) {
                stop("Cannot start: " + estimate.reasons().stream().map(EstimateReason::code).collect(Collectors.joining(", ")));
            }
            System.out.println("The dub costs " + estimate.totalMinutes() + " minutes.");
            return String.valueOf(estimate.totalMinutes());
        }));

        // The SDK makes an Idempotency-Key by itself, but a new one on every run. This key is saved before it is sent.
        Job created = rask.projects().createProject(
                CreateProjectRequest.builder().mediaId(mediaId).targetLanguage("es-es").expectedCostMinutes(maxMinutes).build(),
                RequestOptions.builder().idempotencyKey(once("create_key", () -> UUID.randomUUID().toString())).build()).data();
        Job processed = rask.jobs().wait(created.id()).data();
        if (!(processed.result() instanceof ProjectProcessingJobResult)) {
            stop("Job " + processed.id() + " has no project.");
        }
        ProjectProcessingJobResult processing = (ProjectProcessingJobResult) processed.result();
        String projectId = processing.projectId();

        // That job ends when translation ends. The dub is a job of its own.
        ProjectProcessingAutoDubResult autoDub = processing.autoDub();
        if (AutoDubResultState.DISPATCHED.equals(autoDub.state()) && autoDub.dubJobId() != null) {
            rask.jobs().wait(autoDub.dubJobId());
        } else {
            // The dub was skipped (skippedReason says why) or not requested, and nothing was charged. Dub within the same cap.
            Estimate estimate = rask.dubbing().estimateDub(projectId).data();
            if (estimate.totalMinutes() > maxMinutes) {
                stop("The dub now costs " + estimate.totalMinutes() + " minutes, more than the " + maxMinutes + " you accepted,"
                        + " and nothing was charged. Project " + projectId + " keeps its translation: dub it once you accept"
                        + " the new price.");
            }
            Job dub = rask.dubbing().createDub(projectId,
                    CreateDubRequest.builder().expectedCostMinutes(maxMinutes).build(),
                    RequestOptions.builder().idempotencyKey(once("dub_key", () -> UUID.randomUUID().toString())).build()).data();
            rask.jobs().wait(dub.id());
        }

        // The link is presigned: the download takes no key, and it works for 1 hour.
        for (Output output : rask.exports().listProjectOutputs(projectId).data().data()) {
            if (OutputKind.DUBBED_VIDEO.equals(output.kind()) && output.url() != null) {
                HttpResponse<InputStream> download = HttpClient.newHttpClient().send(
                        HttpRequest.newBuilder(URI.create(output.url())).build(), HttpResponse.BodyHandlers.ofInputStream());
                try (InputStream body = download.body()) {
                    if (download.statusCode() != 200) {
                        stop("The download answered " + download.statusCode() + ". Run this again for a fresh link.");
                    }
                    Files.copy(body, Path.of("dubbed.mp4"), StandardCopyOption.REPLACE_EXISTING);
                }
                System.out.println("Saved dubbed.mp4");
                return;
            }
        }
        stop("Project " + projectId + " has no dubbed video to download.");
    }

    /** The value saved under this name, or a new one, saved before the program goes on. */
    private static String once(String name, Supplier<String> make) {
        if (!STATE.containsKey(name)) {
            STATE.setProperty(name, make.get());
            try (Writer writer = Files.newBufferedWriter(statePath)) {
                STATE.store(writer, null);
            } catch (IOException error) {
                throw new UncheckedIOException(error);
            }
        }
        return STATE.getProperty(name);
    }

    private static void stop(String message) {
        System.err.println(message);
        System.exit(1);
    }
}
```

```php PHP theme={null}
<?php

// Upload a video, dub it into Spanish and download the result: php dub.php talk.mp4
// What it has done is saved in talk.mp4.dub.json. If it stops, run it again: it sends the same keys, and the API replays
// what already ran, so no project or dub is created or charged twice. Delete the file to dub the video anew.

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Rask\Sdk\Enum\AutoDubResultState;
use Rask\Sdk\Enum\OutputKind;
use Rask\Sdk\Exception\ConflictError;
use Rask\Sdk\Exception\ConnectionError;
use Rask\Sdk\Exception\InsufficientBalanceError;
use Rask\Sdk\Exception\JobCanceledError;
use Rask\Sdk\Exception\JobFailedError;
use Rask\Sdk\Exception\RaskError;
use Rask\Sdk\Exception\UsageError;
use Rask\Sdk\Exception\WaitTimeoutError;
use Rask\Sdk\Model\MediaImportJobResult;
use Rask\Sdk\Model\ProjectProcessingJobResult;
use Rask\Sdk\Rask;

/** What the program has done, saved in a file so that a second run goes on from where the first stopped. */
final class Progress
{
    /** @var array<string, string> */
    private array $values = [];

    public function __construct(public readonly string $path)
    {
        if (is_file($path)) {
            /** @var array<string, string> $values */
            $values = json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
            $this->values = $values;
        }
    }

    /**
     * The value saved under this name, or a new one, saved before the program goes on.
     *
     * @param callable(): string $make
     */
    public function once(string $name, callable $make): string
    {
        if (!isset($this->values[$name])) {
            $this->values[$name] = $make();
            file_put_contents($this->path, json_encode($this->values, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
        }
        return $this->values[$name];
    }
}

function stop(string $message): never
{
    fwrite(STDERR, $message . "\n");
    exit(1);
}

$path = $argv[1] ?? 'talk.mp4';
$progress = new Progress($path . '.dub.json');

try {
    $rask = new Rask(); // The key from RASK_API_KEY, and the base URL from RASK_BASE_URL when it is set.

    $mediaId = $progress->once('media_id', function () use ($rask, $path): string {
        // One call uploads the file: createUpload, the PUT to storage and completeUpload. It answers with the import job.
        $importJob = $rask->uploads->uploadFile($path);
        // One call waits for a job, with the API's long-poll. A job that failed is thrown, not returned.
        $imported = $rask->jobs->wait((string) $importJob->id);
        if (!$imported->result instanceof MediaImportJobResult || $imported->result->media_id === null) {
            stop("Job {$imported->id} has no media.");
        }
        return $imported->result->media_id;
    });
    $order = ['media_id' => $mediaId, 'target_language' => 'es-es'];

    // The minutes shown once are the most this program spends: they are the cap of every paid request below.
    $maxMinutes = (int) $progress->once('max_minutes', function () use ($rask, $order): string {
        $estimate = $rask->projects->estimateProject($order);
        if ($estimate->eligible !== true) {
            stop('Cannot start: ' . implode(', ', array_map(fn ($reason) => (string) $reason->code, $estimate->reasons ?? [])));
        }
        echo "The dub costs {$estimate->total_minutes} minutes.\n";
        return (string) $estimate->total_minutes;
    });

    // The SDK makes an Idempotency-Key by itself, but a new one on every run. This key is saved before it is sent.
    $created = $rask->projects->createProject(
        [...$order, 'expected_cost_minutes' => $maxMinutes],
        ['idempotency_key' => $progress->once('create_key', fn (): string => bin2hex(random_bytes(16)))],
    );
    $processed = $rask->jobs->wait((string) $created->id);
    $result = $processed->result;
    if (!$result instanceof ProjectProcessingJobResult || $result->project_id === null) {
        stop("Job {$processed->id} has no project.");
    }
    $projectId = $result->project_id;

    // That job ends when translation ends. The dub is a job of its own.
    $autoDub = $result->auto_dub;
    if ($autoDub?->state === AutoDubResultState::DISPATCHED && $autoDub->dub_job_id !== null) {
        $rask->jobs->wait($autoDub->dub_job_id);
    } else {
        // The dub was skipped (skipped_reason says why) or not requested, and nothing was charged. Dub within the same cap.
        $estimate = $rask->dubbing->estimateDub($projectId);
        if ($estimate->total_minutes > $maxMinutes) {
            stop("The dub now costs {$estimate->total_minutes} minutes, more than the {$maxMinutes} you accepted, and nothing "
                . "was charged. Project {$projectId} keeps its translation: dub it once you accept the new price.");
        }
        $dub = $rask->dubbing->createDub(
            $projectId,
            ['expected_cost_minutes' => $maxMinutes],
            ['idempotency_key' => $progress->once('dub_key', fn (): string => bin2hex(random_bytes(16)))],
        );
        $rask->jobs->wait((string) $dub->id);
    }

    // The link is presigned: the download takes no key, and it works for 1 hour.
    foreach ($rask->exports->listProjectOutputs($projectId)->data ?? [] as $output) {
        if ($output->kind === OutputKind::DUBBED_VIDEO && $output->url !== null) {
            // copy() answers false when storage refuses, as it does once the link has expired.
            if (!copy($output->url, 'dubbed.mp4')) {
                stop('The download failed. Run this again for a fresh link.');
            }
            echo "Saved dubbed.mp4\n";
            exit(0);
        }
    }
    stop("Project {$projectId} has no dubbed video to download.");
} catch (InsufficientBalanceError $error) {
    // credit_type says which balance: minutes, or the project credits that createProject takes.
    stop("{$error->detail} Add to the balance in the web app, then run this again.");
} catch (ConflictError $error) {
    if ($error->code === 'cost_changed') {
        stop("The price is now {$error->current_cost_minutes} minutes, more than the {$error->expected_cost_minutes} you "
            . "accepted, and nothing was charged. Delete {$progress->path} to start again at the new price.");
    }
    stop($error->getMessage());
} catch (JobFailedError $error) {
    stop("Job {$error->job->id} failed: {$error->code}: {$error->detail}");
} catch (JobCanceledError $error) {
    stop("Job {$error->job->id} was canceled.");
} catch (WaitTimeoutError $error) {
    stop("Job {$error->job_id} is still running. Run this again to keep waiting.");
} catch (ConnectionError $error) {
    stop("No answer from the API: {$error->getMessage()}. Run this again: it sends the same keys.");
} catch (RaskError $error) {
    // The base of them all, so it comes last.
    stop($error->getMessage());
} catch (UsageError $error) {
    // No key, or a wrong argument: nothing was sent.
    stop($error->getMessage());
}
```

* **Running it.** The TypeScript file is an ES module, because it awaits at the top level: set `"type": "module"` in
  `package.json`, or name the file `dub.mts`. Node runs a TypeScript file without a build step from version 22.18.
* **The upload** asks for an upload, sends the bytes to storage without your key and completes it. It answers with the
  `media_import` job, whose result names the media. The SDK tells the content type by the file's extension: for a name it
  cannot tell, pass the type.
* **The price.** [estimateProject](https://docs.api.rask.ai/operations/estimateProject.md) gives it as `total_minutes`. The program sends that as
  `expected_cost_minutes`, the most it may spend, and keeps it as the cap of the dub it may start later. The project also
  takes a project credit, which the estimate counts in `total_projects`. While `projects.unlimited` is true in
  [getBalance](https://docs.api.rask.ai/operations/getBalance.md), it takes none, and `total_projects` is 0.
* **The wait** uses the long-poll of [getJob](https://docs.api.rask.ai/operations/getJob.md), for up to 4 hours unless you pass a longer time, as
  [below](#base-url-and-wait-timeout). A `file_restore` job or a long `localization` run can take more:
  [how long a job takes](https://docs.api.rask.ai/v3/concepts/jobs.md#how-long-a-job-takes) gives the times. A job that failed or was canceled comes
  back as an error that holds the job. A wait that ran out of time comes back as an error that holds the job's id, and
  leaves the job running: wait again with that id.
* **The dub is a job of its own.** The job that [createProject](https://docs.api.rask.ai/operations/createProject.md) starts ends when translation
  ends. Its `result.auto_dub` names the `dub_job_id` to wait for when its `state` is `dispatched`. When it is `skipped`
  (`skipped_reason` says why) or `not_requested`, nothing was charged for a dub: the program prices one with
  [estimateDub](https://docs.api.rask.ai/operations/estimateDub.md) and starts it with [createDub](https://docs.api.rask.ai/operations/createDub.md) within the same cap, or
  stops if the price went up.
* **The keys.** Every SDK sends an `Idempotency-Key` by itself and repeats it on its own retries, but a new run makes new
  keys. So the program saves each key in a file next to the video, with the media and the price, before it sends the
  request. Run it again after a crash or a timeout and it sends the same keys: the API replays what already ran and charges
  nothing twice. The API keeps a key 24 hours, and a key replays its job even when the job failed or was canceled, so
  delete the file to start new work. [Idempotency](https://docs.api.rask.ai/v3/concepts/idempotency.md) says why.
* **The download.** An output's link is presigned: it takes no key, and it works for 1 hour.
  [Downloads](https://docs.api.rask.ai/v3/recipes/downloads.md) describes every output.
* **Errors** are one class for each HTTP status under one base class, and each holds the `code` to branch on.
  [Errors](https://docs.api.rask.ai/errors.md) lists the codes. These are the classes a dub program meets, under the same names in every SDK:

| Class | When | What to do |
| - | - | - |
| `InsufficientBalanceError` | 402 `insufficient_balance`: the balance cannot pay. `credit_type` names the balance, minutes or the project credits that createProject takes, with what is needed and what is left | Add to the balance in the web app, then run the program again |
| `ConflictError` | 409. With the `code` `cost_changed`, the price went above `expected_cost_minutes`, and `current_cost_minutes` holds the new one | After `cost_changed`, nothing was charged, and the key can be used again. Once you accept the new price, send it as `expected_cost_minutes`, with the same key or a new one |
| `JobFailedError` | The job failed. Its `code` is the job's error | [Errors](https://docs.api.rask.ai/errors.md) says what each code needs. A new attempt needs a new key |
| `JobCanceledError` | The job was canceled | Start the work again with a new key |
| `WaitTimeoutError` | The wait ran out of time, and the job is still running | Wait again with the job's id |
| `RaskConnectionError` | No answer came, after the SDK's own retries. Go and PHP call it `ConnectionError` | Run the program again: it sends the same keys |
| `RaskError` | Any other error. It is the base class, so check it last | Branch on `code` |

## What every SDK does for you

* **Keeps the key where it belongs.** The key goes only to the base URL. A presigned upload goes without it, and a redirect
  that leaves the base URL drops it.
* **Retries safely.** A request that can be retried is sent again with the same `Idempotency-Key`, so nothing is charged
  twice. [Idempotency](https://docs.api.rask.ai/v3/concepts/idempotency.md) explains the rule the SDK follows.
* **Waits for jobs.** One call waits for a [job](https://docs.api.rask.ai/v3/concepts/jobs.md) to finish, with the long-poll the API offers.
* **Uploads a file in one call**, and pages through a list for you.
* **Survives new values.** A value or a variant this version does not know reaches your code as it came, so a newer API does
  not break an older SDK.
* **Gives typed errors**, with the `code` to branch on. [Errors](https://docs.api.rask.ai/errors.md) lists them.

## Base URL and wait timeout

The default is `https://api.rask.ai/v3`. To send requests through a gateway, set `RASK_BASE_URL` or pass the base URL to the
client. A path prefix is kept. A wait lasts up to 4 hours unless you pass a longer time to it.

| SDK | Base URL | A wait of up to 12 hours |
| - | - | - |
| TypeScript | `new Rask({ baseUrl: url })` | `rask.jobs.wait(jobId, { timeoutSeconds: 12 * 3600 })` |
| Python | `Rask(base_url=url)` | `client.jobs.wait(job_id, timeout=12 * 3600)` |
| Go | `rask.New(rask.WithBaseURL(url))` | `client.Jobs.Wait(ctx, jobID, rask.WithWaitTimeout(12*time.Hour))` |
| Java | `Rask.builder().baseUrl(url).build()` | `rask.jobs().wait(jobId, Duration.ofHours(12))` |
| PHP | `new Rask(['base_url' => $url])` | `$rask->jobs->wait($jobId, 12 * 3600)` |

## Without an SDK

Every guide here uses plain HTTP, and the contract is served at `https://api.rask.ai/v3/openapi.json`, so any OpenAPI
generator can build a client. [Introduction](https://docs.api.rask.ai/v3/introduction.md#read-the-contract-from-code) shows how to read it from code.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.