# Files

> Input files, their processing states, outputs, retries and downloads.

## Input files

Anything placed in a project's `input/` folder is an input file, however it got there: an API
upload, the AWS CLI, or an SDK. The processing service takes every file it finds, including files
in subfolders. In a project that reads a [feed](/vod-express/concepts/inputs#feeds), the
input files are the feed items' media, downloaded by the project itself and named from each item
(`4f1c2b9a7d3e.8a2b4c6d.mp4`); everything on this page applies to them as to uploaded files.

`GET /projects/{id}/files` lists them in name order, each with what it will produce:

```json
{
  "data": [
    {
      "name": "episode 01.mp4",
      "size": 11825290,
      "uploadedAt": "2026-09-28T06:10:46.000Z",
      "status": "done",
      "hash": "a7381118",
      "outputs": [{ "path": "episode 01.es.mp4", "available": true, "size": 12374212 }],
      "failureReason": null,
      "failureCode": null,
      "stale": false,
      "usage": [
        { "kind": "captioning", "language": "en", "durationMs": 1312480 },
        { "kind": "translation", "language": "es", "durationMs": 1312480 },
        { "kind": "dubbing", "language": "es", "durationMs": 1312480 }
      ]
    },
    {
      "name": "season 2/episode 01.mp4",
      "size": 734003200,
      "uploadedAt": "2026-09-28T06:12:03.000Z",
      "status": "pending",
      "hash": null,
      "outputs": [{ "path": "season 2/episode 01.es.mp4", "available": false, "size": null }],
      "failureReason": null,
      "failureCode": null,
      "stale": false,
      "usage": null
    }
  ],
  "meta": { "total": 2, "pending": 1, "processing": 0, "done": 1, "failed": 0 }
}
```

`meta` counts every file by state. The same counts are kept on the project as `files`
([Projects](/vod-express/concepts/projects#the-project-resource)), refreshed by this call, by
every change made through the API and once a minute otherwise, so a list of projects can be
polled instead of each project's files. `meta` counts every file by state. `?status=failed` (or several states, comma-separated) keeps
only those files in `data`, and `GET /projects/{id}/files/{name}` answers one file.

- `name` (string): The file's path under `input/`. A file in a subfolder is `season 2/episode 01.mp4`.
- `status` ("pending" | "processing" | "done" | "failed"): See [States](#states).
- `outputs` (object[]): Every output the project's config produces for this file, one per item of every destination: its `path` under `output/`, whether it is `available` yet, and its `size` once it is. The files of an [output feed](/vod-express/concepts/inputs#the-output-feed) are under `feeds/<language>/` (`feeds/es/4f1c2b9a7d3e.8a2b4c6d.mp4`).
- `failureReason` (string | null): Set when `status` is `failed`.
- `failureCode` (string | null): Set when `status` is `failed`: why, when it is something you can fix. `no_audio` (the file has no audio track), `not_media` (not media, or truncated), `voice_not_found` (the config names a voice the engine does not have), `interrupted` (processing was stopped before it finished; retry), or `internal` (ours). See [When a file failed](/vod-express/guides/monitoring#when-a-file-failed).
- `stale` (boolean): `true` when the input was replaced after it was processed, by copying a new file over it with the AWS CLI. The file reads `pending`, its outputs are still the old content's, and nothing happens until you [retry](#retry) it.
- `usage` (object[] | null): What a `done` file was billed for ([Billing](/vod-express/concepts/how-it-works#billing)): one entry for its captions in the transcription language, then a `translation` for each target language and a `dubbing` for each dubbed one. Each entry has its `kind`, its `language`, and `durationMs`, the file's length that it is billed by. The file's billed minutes are the sum of the entries' durations. A retried file keeps the entries its content was first billed for, because a retry bills nothing new; replaced content is billed again and has its own entries. `usage` is `null` while the file is not `done`, and for up to a minute after it finishes, until it is recorded. `durationMs` is `null` while processing has not reported the file's length; the entry is billed once it has.
- `hash` (string | null): An identifier of the processing run. Quote it when you contact [support](/vod-express/support) about a file.

## States

```mermaid
stateDiagram-v2
  direction LR
  [*] --> pending: uploaded
  pending --> processing: transcribed, translated and dubbed
  processing --> done: outputs written
  pending --> failed
  processing --> failed
  failed --> pending: retry
  done --> pending: retry
```

| State | Meaning |
|---|---|
| `pending` | Waiting, or being transcribed, translated and dubbed. Most of a file's time is spent here. A retried file is `pending` until its new run gets as far as `processing`. |
| `processing` | Speech work is finished and the outputs are being produced. |
| `done` | Every output is written. |
| `failed` | Processing stopped. `failureCode` says why; [retry](#retry) the file. |

A file whose input was replaced after it was processed is `pending` with `stale: true` until you
retry it ([Replace](#replace)).

A file stays `pending` while its project is `stopped`. A project that stopped itself after its last
file starts again within a minute of a new file, however it was uploaded. Completing an API upload
also starts one stopped with `POST …/stop`; neither happens while the project's `startOnNewFiles`
is off. Retrying a file starts a stopped project that ran before either way. Both answer the
project's state in `projectStatus`; a project created with `autoStart: false` that never ran, or
one in `error`, is left as it is. See [Starting again on a new file](/vod-express/concepts/projects#starting-again-on-a-new-file).

The processing service processes several files at a time, and takes the next file from the folder
as soon as one finishes, so a file uploaded while others are being processed starts as soon as
there is room for it.

A file that is being processed when its project stops (a stop, a change of `config` or
`buildVersion`, a restart) is left `pending` and processed again from the start when the project
next runs.

## Subfolders

Subfolders of `input/` are kept in `output/`: with a `nameTemplate` of `${name}.es.${ext}`,
`input/season 2/episode 01.mp4` writes `output/season 2/episode 01.es.mp4`.

In a URL, a file's name is **one** path segment, so encode its slashes along with everything
else: `season%202%2Fepisode%2001.mp4`. Every page that takes a `{name}` points here. Generated
clients do this for you ([Generate a client](/vod-express/guides/clients)); by hand:

| | Encode a name |
|---|---|
| Shell | `jq -rn --arg n "$NAME" '$n \| @uri'` |
| Python | `urllib.parse.quote(name, safe="")` |
| JavaScript | `encodeURIComponent(name)` |

```bash
NAME=$(jq -rn --arg n 'season 2/episode 01.mp4' '$n | @uri')   # season%202%2Fepisode%2001.mp4
curl -s -X POST -H "$AUTH" $API/projects/$PROJECT/files/$NAME/retry
```

## Output names must not collide

Outputs are named from the input **without its extension**, so `trailer.mov` and `trailer.mkv`
would both write `trailer.es.mp4`, and the second would overwrite the first. An API upload refuses
such a name with `422` (`errors.name`). A file copied in with the AWS CLI is not checked, so give
inputs distinct base names.

A name must not contain `${`: output names are made by putting the input's name into
`${name}`, and a `${` left over fails the file. An API upload refuses such a name.

## Retry

```bash
curl -s -X POST -H "$AUTH" $API/projects/$PROJECT/files/episode%2001.mp4/retry
```

```json 202
{ "data": { "name": "episode 01.mp4", "status": "pending", "projectStatus": "running" } }
```

The file is processed again from the start the next time the service scans the folder, with the
project's current config. Retrying works on `done` files too, for example after changing the
config. **A retried file is not billed again** unless its content changed.

A project that ran and has stopped since is started again. `projectStatus` says what the
project is doing now: `stopped` if it never ran (start it once every file is uploaded), `error` if
its last start failed.

## Replace

Upload a file under the same name to replace a `done` one:

- **Through the API**, set `overwrite: true` on the upload (without it, a same-name upload answers
  `409`). The new file is processed from scratch. While the project is running, a file that is
  neither `done` nor `failed` may be mid-run, and replacing it answers `409` (`file_processing`).
- **With the AWS CLI**, copy the new file over the old one, then [retry](#retry) it. Until the
  retry, the file reads `pending` with `stale: true`, and is not processed again.

New content is billed as a new file.

> **Warning:**
> **Replace a `failed` file under a new name**, and delete the failed one. For now the processing
> service keeps its own copy of a failed input, found by the file's name, and processes that copy
> again when the name comes back: `overwrite: true`, copying over it, or deleting it and uploading
> it again would all fail the same way on the old bytes.

## Download

| What | Call |
|---|---|
| An output | `GET /projects/{id}/outputs/download?path=episode%2001.es.mp4` |
| An input | `GET /projects/{id}/files/{name}/download` |
| Every output of the project | `GET /projects/{id}/outputs`, output feeds included (`feeds/es.xml` and the files under `feeds/es/`) |

The download calls answer a presigned S3 URL, valid for up to an hour. `expiresAt` is when it
stops working, which can be sooner than an hour; fetch a new one after that, rather than storing
it:

```json
{
  "data": {
    "url": "https://sw-vod-express.s3.us-east-1.amazonaws.com/projects/141/…/output/episode%2001.es.mp4?X-Amz-Algorithm=…",
    "expiresAt": "2026-09-28T07:18:40.000Z"
  }
}
```

Fetch it with any HTTP client, without the `Authorization` header. A browser saves the file
under its own name instead of playing it. With an [upload
role](/vod-express/uploads/aws-role), `aws s3 sync <outputPath> ./results` fetches everything at once.

## Delete

`DELETE /projects/{id}/files/{name}` deletes an input file and its processing records. **Its
outputs are kept**; they stay listed by `GET …/outputs`.

While the project is running, a file that is neither `done` nor `failed` may be in the middle of
its run, which would finish and record the deleted file as done. Such a delete answers `409`
(`file_processing`): wait for the file, or stop the project, then delete it.

## Metadata

`GET /projects/{id}/files/{name}/meta` returns what the processing service recorded about the
file's latest run, filled in as it goes: `hash` and `startedAt` when it starts, `durationMs` once
the media is read, `dubbingMs` after transcription, translation and dubbing, `finishedAt` when the
outputs are written.

```json
{
  "hash": "ed0c5ebd",
  "inputVersion": "\"9b2cf535f27731c974343645a3985328\"",
  "startedAt": "2026-09-28T06:11:02.114Z",
  "finishedAt": "2026-09-28T06:40:51.872Z",
  "durationMs": 1312480,
  "dubbingMs": 1388617
}
```

It answers `404` (`file_meta_not_found`) until the run starts. A run before 2026-10-09 may carry
other fields.
