# Episodes

> Read, label, trim, split and rule on a dataset's episodes

`client.episodes` reads and edits the takes of a dataset, the way the episode
studio does. It acts in the API key's own account, so it needs no account id
(see [Configuration](https://junction-docs.qualiastudios.dev/sdk/configuration.md#account)). An episode is named through the
dataset it is read from, `accounts/{a}/datasets/{d}/episodes/{e}`; a method on
one episode takes that full name, or its id with `dataset=`. Replies are the
generated `qualia.v1` models.

An API key acts as the person who made it, inside that key's organization and
scopes, with the same permissions that person has in the dashboard. A take
someone has open in the studio is theirs while they edit it: a single write made
with a key is refused, and a bulk write skips it and reports it as locked.

```python
from qualia import Qualia

client = Qualia()
upload = "accounts/<ACCOUNT_UUID>/datasets/<DATASET_UUID>"

page = client.episodes.list(upload, filter="state = UNREVIEWED", limit=50)
episode = client.episodes.get(page.items[0].name)
```

## Read

| Method                                                                    | What it reads                                                    |
| ------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `list(dataset, filter=, order_by=, view=, version=, limit=, cursor=)`     | One page of episodes, in recording order                         |
| `get(episode, dataset=, view=, version=)`                                 | One episode, with its posters and markers                        |
| `locate(episode, dataset=, filter=, order_by=, limit=, version=)`         | Its position in a filtered list, and its neighbours              |
| `summarize(dataset, filter=, facets=, metrics=, bin_count=)`              | Counts, facets and metric spreads over what a filter leaves      |
| `sign_videos(episode, dataset=, version=)`                                | One short-lived signed link per camera                           |
| `sample_series(episode, dataset=, max_points=, streams=, version=)`       | State, action or TCP channels, sampled evenly over the take      |
| `batch_sample_series(dataset, episodes, max_points=, streams=, version=)` | The same for up to 50 takes in one call                          |
| `revisions(episode, dataset=, revisions=, limit=, cursor=)`               | The take's edits, newest first, then the recording as revision 0 |

### Issues and notes in a filter

An open issue blocks an episode: `open_issue_count` counts them and
`open_issue_codes` names their codes. A note does not block it: every
recording (heuristic) and graph check finding is a note, and so is a VLM
verdict below its confidence bar or one the model was unsure of. `note_count`
counts an episode's open notes, and filters and sorts like `open_issue_count`:

```python
noted = client.episodes.list(upload, filter="note_count > 0", order_by="note_count desc")
clean = client.episodes.summarize(upload, filter="open_issue_count = 0 AND note_count = 0")
```

A note has no code to filter by; read which check wrote it with
`client.checks.list_findings`.

`version=` reads as a version froze it: a tag, `"stable"`, `"v0"` or a digest
prefix. Each episode then carries the fields, trim and subtasks of the revision
that version pinned; its standing, issues and markers are current. Leave it out
(or pass `"head"`) to read the episodes as they are now.

```python
frozen = client.episodes.get(episode.name, version="1.0")
print(frozen.revision, frozen.trim)
```

`sign_videos` links stop working at each video's `expire_time`; call it again
for fresh ones. When a camera file holds more than the take, `start_seconds` and
`end_seconds` say where the take is in it. A take in no published version yet (a
split child before its publish) answers `unpublished=True` and no videos.

`streams=` names the vectors to sample: `"STATE"`, `"ACTION"` and `"TCP"` (a
handheld take resolved at its tool centre points). Left out, it samples STATE
and ACTION. `batch_sample_series` answers one result per take, in the order
asked; a take that cannot be read fails alone, with `series` unset and
`error_code` / `error_message` set. The takes times `max_points` (400 by
default) must stay at or under 20000.

```python
batch = client.episodes.batch_sample_series(
    upload, [e.name for e in page.items[:10]], max_points=200, streams=["STATE"]
)
for result in batch.results:
    if result.series is None:
        print(result.name, result.error_message)
```

### Edit history

A revision holds the take's whole content after the edit, not the change. What
an edit changed is its difference from the revision before it. Rulings (keep,
reject) are not revisions, and an episode nobody edited lists nothing.

```python
history = client.episodes.revisions(episode.name, limit=20)
for revision in history.items:
    who = revision.author.display_name if revision.author else ""
    print(revision.revision, revision.create_time, who, revision.note)

# exactly one edit and the one before it
pair = client.episodes.revisions(episode.name, revisions=[4, 3])
```

## Edit

`update(episode, ...)` edits one take and writes one revision. Only the
arguments given change. `update_many(dataset, edits)` makes up to 500 such edits
in one call, all or none, each an `EpisodeEdit` or a mapping of its fields.

| Argument                                 | Meaning                                                                         |
| ---------------------------------------- | ------------------------------------------------------------------------------- |
| `task`, `instruction`, `scene`, `method` | Text labels; `""` clears one                                                    |
| `tags`                                   | Replaces the free tags; `[]` clears them                                        |
| `outcome`                                | `"SUCCESS"`, `"PARTIAL"`, `"FAILURE"` or `"OUTCOME_UNSPECIFIED"`                |
| `trim` / `clear_trim=True`               | The trim window `{"start_seconds", "end_seconds"}`, or the full recording again |
| `subtasks`                               | Subtask spans `{"start_seconds", "end_seconds", "label"}`; they replace the set |
| `etag`                                   | The etag you read; a stale one is refused (`QualiaAPIError`, code `ABORTED`)    |
| `request_id`                             | Idempotency key; a retry with the same one returns the first result             |

```python
client.episodes.update(
    episode.name,
    outcome="FAILURE",
    tags=["slow"],
    trim={"start_seconds": 1.5, "end_seconds": 12.0},
    etag=episode.etag,
)
```

Other writers in the same account can keep an edit from publishing for longer
than the server retries it. The server then answers 409 "account inputs
changed" and writes nothing, and the SDK sends the same call again, up to 3
tries, 1 s then 2 s apart. The `etag`, or the revisions a `request_id` froze on
its first call, are checked again each time, so a change another writer made to
the same episode is still refused.

Trims and task links are made one episode per call. `trim_to_markers`,
`clear_trims` and `name_subtasks` do it across many episodes from their own
recorder markers ([Data](https://junction-docs.qualiastudios.dev/sdk/resources/data.md) has examples).

## Split

`split(dataset, cuts, keep=, validate_only=)` cuts episodes of an upload into
child episodes. `cuts` maps an episode to its cut points in seconds. `keep` maps
an episode to the windows to keep instead, `(start, end)` in time order and not
overlapping: each window becomes one child, and what lies outside every window
is in no child. An episode is cut one way, not both. `validate_only=True`
reports the children without writing them.

```python
plan = client.episodes.split(
    upload, keep={episode.name: [(0.5, 6.0), (8.0, 14.0)]}, validate_only=True
)
split = plan.splits[0]
print(split.children, split.dropped_between_frame_count)
```

`split_at_markers` cuts at recorder markers, and `unsplit` removes children that
were never published.

## Rule

`keep`, `reject`, `reset` and `restore` take the dataset, then episode ids or
names, or one `filter=`. They answer a `RulingResult` with the changed,
unchanged, skipped and locked counts.

```python
result = client.episodes.reject(upload, filter='open_issue_codes:"jerky"', reason="jerky")
print(result.changed_count, result.locked_count)
```

## Link to a Task

`link_task(episode, task, dataset=, etag=, request_id=)` links one take to a
Task, or removes its own link with `""`. A link is not an edit: the label,
revisions and versions stay as they are. Unlinked, the take reads the Task of
the dataset that holds it again (`linked_task_inherited`). `assign_task` labels
and links many takes at once, and `link_task_labels` links the takes whose label
names a Task.

```python
linked = client.episodes.link_task(episode.name, "<TASK_UUID>", etag=episode.etag)
client.episodes.link_task(linked.name, "")  # back to the dataset's Task
```
