# Automate the whole flow

> A complete client in Node.js or Python — token, project, upload, wait, download.

A single script that dubs a folder of videos: it creates a token (and a fresh one when the token
expires), finds its project by name or creates it (with an
[`Idempotency-Key`](/vod-express/guides/errors#repeating-a-create), so a retried create makes one project), uploads each file through the API, starts the
project, waits for every file, and downloads the outputs. No AWS account is needed, and running it
again reuses the project.

```bash
export SW_PUBLIC_KEY=… SW_SECRET_KEY=…
node dub.mjs ./videos ./dubbed          # or: python dub.py ./videos ./dubbed
```

`SW_VOD_API` overrides the base URL ([Environments](/vod-express/environments)).

```javascript dub.mjs (Node.js 20+)
import { randomUUID } from 'node:crypto';
import { mkdir, open, readdir, stat, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';

const API = process.env.SW_VOD_API ?? 'https://api-vod-express.syncwords.com/v1';
const PROJECT_NAME = 'Dub a folder — Spanish';
const [inputDir, outputDir] = process.argv.slice(2);

const CONFIG = {
sourceMedia: { '@type': 'S3FolderSource' },
transcription: { '@type': 'Speechmatics', language: 'en' },
translations: [
  { language: 'es', engine: { '@type': 'Gemini' }, ttsOptions: { provider: { '@type': 'Deepdub', voice: 'auto' } } },
],
outputs: [
  {
    '@type': 'S3FolderOutput',
    items: [
      {
        '@type': 'VideoFile',
        ext: 'mp4',
        nameTemplate: '${name}.es.${ext}',
        useOriginalTracks: true,
        dubTracks: [{ language: 'es', title: 'Spanish' }],
      },
    ],
  },
],
};

// --- Authentication: a token from the key pair, and a new one on 401 ---------------------------
let token = process.env.SW_TOKEN;

async function newToken() {
const response = await fetch(`${API}/token/create`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
  body: JSON.stringify({ username: process.env.SW_PUBLIC_KEY, password: process.env.SW_SECRET_KEY }),
});
if (!response.ok) throw new Error(`token: ${response.status} ${await response.text()}`);
token = (await response.json()).access_token;
}

async function api(method, path, body, headers = {}, retried = false) {
if (!token) await newToken();
const response = await fetch(`${API}${path}`, {
  method,
  headers: {
    Authorization: `Bearer ${token}`,
    Accept: 'application/json',
    ...(body ? { 'Content-Type': 'application/json' } : {}),
    ...headers,
  },
  body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 401 && !retried) {
  await newToken();
  return api(method, path, body, headers, true);
}
if (response.status === 204) return null;
const json = await response.json();
if (!response.ok) {
  const message = `${method} ${path}: ${response.status} ${JSON.stringify(json)}`;
  throw Object.assign(new Error(message), { status: response.status });
}
return json.data;
}

// A create repeated with the same Idempotency-Key answers the first one's project, so a dropped
// connection, a 5xx or a 409 (the first call still running) can be retried without making two.
async function createProject(body) {
const key = randomUUID();
for (let attempt = 1; ; attempt++) {
  try {
    return await api('POST', '/projects', body, { 'Idempotency-Key': key });
  } catch (error) {
    const retryable = error.status === undefined || error.status === 409 || error.status >= 500;
    if (!retryable || attempt === 3) throw error;
    await new Promise((resolve) => setTimeout(resolve, 2000 * attempt));
  }
}
}

// --- Upload one file through the API ------------------------------------------------------------
async function upload(projectId, file, name) {
const { size } = await stat(file);
const started = await api('POST', `/projects/${projectId}/uploads`, { name, size });
const base = `/projects/${projectId}/uploads/${encodeURIComponent(started.uploadId)}`;
const key = encodeURIComponent(started.key);
const handle = await open(file);
const parts = [];
try {
  for (let n = 1; n <= started.partCount; n++) {
    const buffer = Buffer.alloc(Math.min(started.partSize, size - (n - 1) * started.partSize));
    await handle.read(buffer, 0, buffer.length, (n - 1) * started.partSize);
    const { url } = await api('GET', `${base}/parts/${n}?key=${key}`);
    const put = await fetch(url, { method: 'PUT', body: buffer });
    if (!put.ok) throw new Error(`${name} part ${n}: S3 answered ${put.status}`);
    parts.push({ partNumber: n, etag: put.headers.get('etag') });
  }
} catch (error) {
  await api('DELETE', `${base}?key=${key}`);
  throw error;
} finally {
  await handle.close();
}
await api('POST', `${base}/complete`, { key: started.key, parts });
}

// --- The flow -----------------------------------------------------------------------------------
// One project per configuration: found by its exact name, so a later run reuses it, and created
// only the first time.
const [found] = await api('GET', `/projects?name=${encodeURIComponent(PROJECT_NAME)}`);
const project =
found ?? (await createProject({ name: PROJECT_NAME, config: CONFIG, options: { autoStart: false } }));
console.log(`project ${project.id}`);

// Files a previous run uploaded are in the project already.
const uploaded = new Set((await api('GET', `/projects/${project.id}/files`)).map((f) => f.name));
const videos = (await readdir(inputDir)).filter((f) => /\.(mp4|mov|mkv)$/i.test(f) && !uploaded.has(f));
for (const video of videos) {
await upload(project.id, join(inputDir, video), video);
console.log(`uploaded ${video}`);
}

await api('POST', `/projects/${project.id}/start`);

let files;
do {
await new Promise((resolve) => setTimeout(resolve, 30_000));
files = await api('GET', `/projects/${project.id}/files`);
console.log(files.map((f) => `${f.name}: ${f.status}`).join(', '));
} while (files.some((f) => f.status === 'pending' || f.status === 'processing'));

for (const file of files) {
if (file.status === 'failed') {
  console.error(`${file.name} failed: ${file.failureReason}`);
  continue;
}
for (const output of file.outputs.filter((o) => o.available)) {
  const { url } = await api('GET', `/projects/${project.id}/outputs/download?path=${encodeURIComponent(output.path)}`);
  const target = join(outputDir, output.path);
  await mkdir(dirname(target), { recursive: true });
  await writeFile(target, Buffer.from(await (await fetch(url)).arrayBuffer()));
  console.log(`downloaded ${target}`);
}
}
```

```python dub.py (Python 3.9+)
import os
import re
import sys
import time
import uuid
from urllib.parse import quote

import requests

API = os.environ.get("SW_VOD_API", "https://api-vod-express.syncwords.com/v1")
PROJECT_NAME = "Dub a folder — Spanish"
input_dir, output_dir = sys.argv[1], sys.argv[2]

CONFIG = {
  "sourceMedia": {"@type": "S3FolderSource"},
  "transcription": {"@type": "Speechmatics", "language": "en"},
  "translations": [
      {"language": "es", "engine": {"@type": "Gemini"}, "ttsOptions": {"provider": {"@type": "Deepdub", "voice": "auto"}}}
  ],
  "outputs": [
      {
          "@type": "S3FolderOutput",
          "items": [
              {
                  "@type": "VideoFile",
                  "ext": "mp4",
                  "nameTemplate": "${name}.es.${ext}",
                  "useOriginalTracks": True,
                  "dubTracks": [{"language": "es", "title": "Spanish"}],
              }
          ],
      }
  ],
}

# --- Authentication: a token from the key pair, and a new one on 401 ----------------------------
token = os.environ.get("SW_TOKEN")

def new_token():
  global token
  response = requests.post(
      API + "/token/create",
      json={"username": os.environ["SW_PUBLIC_KEY"], "password": os.environ["SW_SECRET_KEY"]},
      headers={"Accept": "application/json"},
  )
  response.raise_for_status()
  token = response.json()["access_token"]

class ApiError(RuntimeError):
  def __init__(self, status, message):
      super().__init__(message)
      self.status = status

def api(method, path, body=None, headers=None, retried=False):
  if not token:
      new_token()
  response = requests.request(
      method, API + path, json=body,
      headers={"Authorization": f"Bearer {token}", "Accept": "application/json", **(headers or {})},
  )
  if response.status_code == 401 and not retried:
      new_token()
      return api(method, path, body, headers, retried=True)
  if not response.ok:
      raise ApiError(response.status_code, f"{method} {path}: {response.status_code} {response.text}")
  return response.json().get("data") if response.content else None

# A create repeated with the same Idempotency-Key answers the first one's project, so a dropped
# connection, a 5xx or a 409 (the first call still running) can be retried without making two.
def create_project(body):
  key = str(uuid.uuid4())
  for attempt in range(1, 4):
      try:
          return api("POST", "/projects", body, headers={"Idempotency-Key": key})
      except (requests.ConnectionError, requests.Timeout, ApiError) as error:
          status = getattr(error, "status", None)
          retryable = status is None or status == 409 or status >= 500
          if not retryable or attempt == 3:
              raise
          time.sleep(2 * attempt)

# --- Upload one file through the API -------------------------------------------------------------
def upload(project_id, file, name):
  started = api("POST", f"/projects/{project_id}/uploads", {"name": name, "size": os.path.getsize(file)})
  base = f"/projects/{project_id}/uploads/{quote(started['uploadId'], safe='')}"
  key = quote(started["key"], safe="")
  parts = []
  try:
      with open(file, "rb") as handle:
          for n in range(1, started["partCount"] + 1):
              url = api("GET", f"{base}/parts/{n}?key={key}")["url"]
              put = requests.put(url, data=handle.read(started["partSize"]))
              put.raise_for_status()
              parts.append({"partNumber": n, "etag": put.headers["ETag"]})
  except Exception:
      api("DELETE", f"{base}?key={key}")
      raise
  api("POST", f"{base}/complete", {"key": started["key"], "parts": parts})

# --- The flow ------------------------------------------------------------------------------------
# One project per configuration: found by its exact name, so a later run reuses it, and created
# only the first time.
found = api("GET", f"/projects?name={quote(PROJECT_NAME, safe='')}")
project = found[0] if found else create_project({
  "name": PROJECT_NAME,
  "config": CONFIG,
  "options": {"autoStart": False},
})
print("project", project["id"])

# Files a previous run uploaded are in the project already.
uploaded = {f["name"] for f in api("GET", f"/projects/{project['id']}/files")}
for video in sorted(os.listdir(input_dir)):
  if re.search(r"\.(mp4|mov|mkv)$", video, re.I) and video not in uploaded:
      upload(project["id"], os.path.join(input_dir, video), video)
      print("uploaded", video)

api("POST", f"/projects/{project['id']}/start")

while True:
  time.sleep(30)
  files = api("GET", f"/projects/{project['id']}/files")
  print(", ".join(f"{f['name']}: {f['status']}" for f in files))
  if all(f["status"] in ("done", "failed") for f in files):
      break

for file in files:
  if file["status"] == "failed":
      print(f"{file['name']} failed: {file['failureReason']}", file=sys.stderr)
      continue
  for output in (o for o in file["outputs"] if o["available"]):
      url = api("GET", f"/projects/{project['id']}/outputs/download?path={quote(output['path'], safe='')}")["url"]
      target = os.path.join(output_dir, output["path"])
      os.makedirs(os.path.dirname(target), exist_ok=True)
      with requests.get(url, stream=True) as response, open(target, "wb") as out:
          for chunk in response.iter_content(1 << 20):
              out.write(chunk)
      print("downloaded", target)
```

## Taking it further

- **Parallel parts.** Send several parts of a file at once for faster uploads of large files.
- **Retry failures** with `POST /projects/{id}/files/{name}/retry`, then `start`.
- **Stream downloads.** The Node.js example buffers each output in memory for brevity; for large
  files, pipe `fetch(url).body` into a file stream.
