# 文件与版本

空间、路径和节点；通过块网关上传和下载；版本历史、版本命名和比较。

## 空间和路径

```sh
curl -H "Authorization: Bearer $SIDESPACE_TOKEN" https://side.example.com/v1/spaces
```

每个空间有一个 `root_node_id`。它下面的每个文件和文件夹都是一个带 id（`nod_…`）的节点。可以按 id 操作，也可以按路径：

| 要做的事 | 调用 |
|---|---|
| 找到某个路径上的节点 | `GET /v1/spaces/{sid}/paths/Ep01/Shots/A001_C003.mov` |
| 列出文件夹（按名字排序、分页） | `GET /v1/spaces/{sid}/nodes/{nid}/children?limit=200&cursor=…` |
| 新建文件夹或空文件 | `POST /v1/spaces/{sid}/nodes`，带 `parent_id`、`name`、`kind`（`dir` 或 `file`） |
| 改名或移动 | `PATCH /v1/spaces/{sid}/nodes/{nid}`，带 `name` 和/或 `parent_id` |
| 删除（进回收站，30 天） | `DELETE /v1/spaces/{sid}/nodes/{nid}` |
| 从回收站恢复 | `POST /v1/spaces/{sid}/nodes/{nid}/restore` |

同一文件夹里名字不能重复（不区分大小写）。新建时名字已被占用，默认的 `if_exists: "rename"` 会生成 `A001_C003 (2).mov`；`if_exists: "reuse"` 则返回已有的那个节点，这样两个程序保存同一个路径，结果是一个文件、两个版本。

`PATCH` 可以带 `If-Match: <meta_rev>` 请求头。如果在你读取之后有人改了名或移动了它，会返回 `412`，而不是覆盖别人的改动。

SDK 和 CLI 处处都接受路径，帮你解析成 id：

```sh
sidespace ls "Film A" Ep01/Shots
sidespace mv "Film A" Ep01/old.mov Ep01/Archive/old.mov
sidespace rm "Film A" Ep01/tmp
```

## 文件内容怎么传

API 服务器从不经手文件内容。它发放访问**块网关**的短期**票据**；块网关把内容切成加密、去重的块（按内容切分，BLAKE3 哈希）存储，并负责流式读写。

- 同一批素材上传两次，或者只改了元数据的重新导出，只会传网关里没有的块。
- 读取支持 HTTP `Range`，网关只读这个范围涉及的块。播放器可以直接从 200 GB 文件的中间开始播。
- 票据有效期 15 分钟；播放器用的票据加 `"stream": true`，有效 6 小时。

## 上传

SDK 和 CLI 一次调用就完成（`side.upload(space, path, data)`）。直接用 HTTP 时分三步。

**1. 拿到节点**（新建，或者沿用同名的已有文件）：

```sh
curl -X POST https://side.example.com/v1/spaces/$SPACE/nodes \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" \
  -d '{"parent_id": "'$FOLDER'", "name": "A001_C003.mov", "kind": "file", "if_exists": "reuse"}'
```

**2. 传内容。** 开一个上传会话拿到上传票据，然后把整个文件流式发给网关。网关负责切块、存储，返回 **manifest**。

```sh
curl -X POST https://side.example.com/v1/spaces/$SPACE/upload-sessions \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" -d '{"hashes": []}'
# {"missing": [], "ticket": "…", "blockgw_url": "https://blocks.side.example.com", "expires_at": "…"}

curl -X POST "https://blocks.side.example.com/stream-upload?t=$TICKET" --data-binary @A001_C003.mov
# {"algo": "fastcdc-v1", "size": 734003200, "chunks": [{"hash": "9f2c…", "offset": 0, "len": 1048576}, …]}
```

在本地切块的客户端（Mac 应用和 CLI 就是这样）把块哈希放进 `hashes`，只用 `PUT /chunks/{hash}?t=…` 上传 `missing` 里的块，manifest 自己组装。

**3. 提交版本：**

```sh
curl -X POST https://side.example.com/v1/spaces/$SPACE/nodes/$NODE/versions \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" \
  -d '{"base_version_id": "ver_…", "manifest": {…}, "mime": "video/quicktime", "client_mtime": "2026-11-14T09:30:00Z"}'
```

`base_version_id` 是你基于的那个版本（新文件填 `null`）。如果期间有人也保存了，你的版本照样成为当前版本（后保存的生效），返回里的 `superseded` 写明被替换的版本，它的作者会收到通知。什么都不会丢：两个版本都在历史里。缺块时返回 `409 missing_chunks`。

## 下载

先要票据，再从网关读：

```sh
curl -X POST https://side.example.com/v1/spaces/$SPACE/download-tickets \
  -H "Authorization: Bearer $SIDESPACE_TOKEN" -H "Content-Type: application/json" \
  -d '{"version_id": "ver_…"}'
# {"ticket": "…", "manifest": {…}, "blockgw_url": "https://blocks.side.example.com", "expires_at": "…"}

curl -H "Range: bytes=0-1048575" "https://blocks.side.example.com/files/ver_…?t=$TICKET" -o head.bin
```

地址上加 `download=<文件名>` 会返回 `Content-Disposition: attachment`，方便做浏览器里的下载链接。网关对 `/files/*` 返回 CORS 头，所以网页可以直接播放或读取文件。

```ts tab=TypeScript
const bytes = await side.download("Film A", "/Ep01/notes.md");
const res = await side.open("Film A", "/Ep01/Shots/A001_C003.mov", { range: [0, 1_048_575] }); // 一个 fetch Response
```

```python tab=Python
data = side.download("Film A", "/Ep01/notes.md")
with side.open("Film A", "/Ep01/Shots/A001_C003.mov", range=(0, 1048575)) as r:
    head = r.read()
```

### 预览文件（rendition）

服务器会给图片和视频生成 `thumb`（封面图），给视频生成 `proxy`：720p 的 H.264 流，不管原片是什么编码、多大，任何浏览器都能马上播放。要票据时加 `"rendition": "thumb"` 或 `"proxy"` 就能拿到。你自己的工具也可以用 `PUT /v1/spaces/{sid}/versions/{vid}/renditions/{kind}` 附上预览（需要 `renditions:write`）。

## 版本

```sh
sidespace versions "Film A" Ep01/cut.mp4
sidespace get "Film A" Ep01/cut.mp4 cut_v3.mp4 --version 3
sidespace label "Film A" Ep01/cut.mp4 3 "给客户的版本"
sidespace compare "Film A" Ep01/cut.mp4 "给客户的版本"     # 和当前版本比较
sidespace restore "Film A" ver_01JD…
```

| 要做的事 | 调用 |
|---|---|
| 历史，最新的在前 | `GET /v1/spaces/{sid}/nodes/{nid}/versions` |
| 让旧版本重新成为当前版本（复制一份，历史保留） | `POST /v1/spaces/{sid}/versions/{vid}/restore` |
| 给版本起名，或用 `""` 清掉 | `PUT /v1/spaces/{sid}/versions/{vid}/label` |
| 两个版本之间改了什么 | `GET /v1/spaces/{sid}/versions/{a}/compare/{b}` |

同一个文件里，一个名字同时只属于一个版本（不区分大小写）：把“给客户的版本”给了另一个版本，名字就会移过去。CLI 和 MCP 里凡是要填版本的地方，都可以写 id、序号（`3` 或 `v3`）或者版本名。

**比较**不下载任何东西，只看 manifest 和附加信息：

```json
{
  "same_content": false,
  "size_delta": -1048576,
  "bytes": { "shared": 690000000, "only_a": 44003200, "only_b": 42954624, "shared_ratio": 0.94 },
  "media": [{ "field": "duration_s", "a": 92.4, "b": 88.0 }],
  "sidecars": [{ "namespace": "ai.tags", "change": "changed", "fields": ["tags"] }],
  "renditions": { "ver_A…": ["thumb", "proxy"], "ver_B…": ["thumb"] }
}
```

`shared_ratio` 接近 1，说明只改了元数据或者剪短了一点；接近 0 说明是新素材。

想看整个文件夹在某一刻的样子，或者把它整体退回去，见[看过去和整体回滚](/docs/history)。

## 只改文件的一部分

不用重传整个文件，就能改中间的几个字节、插入、删掉一段、追加或截断，结果存成新版本。只有改动附近的数据块是新的，其余照旧复用，所以大文件也很快。

```sh tab=curl
curl -X POST -H "Authorization: Bearer $SIDESPACE_TOKEN" -H 'If-Match: "ver_…"' -H "Content-Type: application/json" \
  "$SIDESPACE_URL/v1/spaces/$SPACE/nodes/$NODE/edits" \
  -d '{"ops": [{"at": 120, "delete": 4, "text": "night"}, {"at": 2048, "text": "\nINT. HARBOR"}]}'
```

```ts tab=TypeScript
await side.edit("Film A", "/Ep01/script.md", [{ at: 120, delete: 4, text: "night" }], { ifVersionId: "ver_…" });
await side.appendText("Film A", "/Ep01/log.txt", "09:41 补拍完成\n");
```

```python tab=Python
side.edit("Film A", "/Ep01/script.md", [{"at": 120, "delete": 4, "text": "night"}], if_version_id="ver_…")
side.append_text("Film A", "/Ep01/log.txt", "09:41 补拍完成\n")
```

- 每个操作把 `at` 开始的 `delete` 个字节换成 `text`（或 `base64` 字节）：`delete` 为 0 是插入，不带数据是删除，`at` 等于文件大小是追加。`truncate` 最后把结果截到多少字节。
- 偏移量都按要改的那个版本、改动之前的位置算，范围不能重叠；一次最多 10,000 个操作、4 MiB 新数据。
- `If-Match`（或请求体里的 `if_version_id`）写你读到的版本。之后有人存过，就返回 `412`，什么都不改，不会改到你没看过的内容上。不写的话改的是当前版本；改的过程中有人保存，也会失败。
- 通过 MCP：`edit_text` 替换一段原文，`append_text` 在末尾追加，见 [MCP](/docs/mcp#工具)。

## 同步与在线状态

- `GET /v1/spaces/{sid}/snapshot` 分页列出所有现存节点，用于第一次同步。之后从 snapshot 的 `at_seq` 开始读[变更日志](/docs/changes)。
- `POST /v1/spaces/{sid}/presence`，带 `{"node_id", "state": "viewing" | "editing" | "none"}`，告诉别人你正在看或改哪个文件。不续报的话 90 秒后失效。`GET` 列出所有人的状态，实时连接也会推送。
