---
title: "公開 API"
description: "用你自己的軟體把內容送上螢幕 — 權杖、圖層堆疊、端點、限制與稽核紀錄。"
---

> Documentation Index
> Fetch the complete documentation index at: https://anysign.tv/llms.txt
> Use this file to discover all available pages before exploring further.

# 公開 API

一個 API，兩種憑證。主控台和任何程式一樣，都是這個 API 的使用者：
人用 session cookie，程式用 bearer 權杖，背後是同一組路由。所有路徑都在
`/api/v1` 底下；這一頁是我們建議整合程式依賴的精選子集。

## 取得權杖

以管理員身分到 **團隊 → API 權杖 → 新增權杖**。密鑰只顯示一次，系統只
保存它的雜湊值。撤銷立即生效。

權杖擁有**成員的權限** — 它改變螢幕顯示什麼，永遠不動螢幕本身。它不能
認領螢幕、重新開機、重新設定螢幕、邀請任何人，也不能再核發權杖；所有
標示*管理員*的操作一律回應 `403`。

每個請求都帶上它：

```
Authorization: Bearer anysign_<64 hex characters>
```

錯誤在所有地方都用同一種格式：機器可讀的 `reason`、一句英文 `message`，
以及在句子提到限制數值時附上的 `params`。

## 模型：堆疊，不是插槽

螢幕的內容是**圖層堆疊**，螢幕顯示的是最高一層有東西的圖層：

```
emergency      全組織強制接管
takeover       「現在就播這個」— 操作人員的，或你的
source         由整合程式持有的圖層
scheduled      分時段排程
base           日常輪播
```

寫入一層永遠不影響其他層；移除一層，底下的內容就會透出來。讓多方共用
螢幕變得安全的慣例是：**整合程式擁有的是一個圖層，不是一個螢幕。**
只寫你自己的那一層，操作人員永遠可以在你上面把螢幕接管回去，也永遠
看得到你蓋住了什麼。

## 限制

### `GET /limits`

這個 API 用來衡量請求的上限值。用讀的，不要寫死 — 這些數值可以調高，
你這邊不必改版。

```jsonc
{
  "upload": { "maxBytes": 33554432 },
  "image": {
"maxBytes": 33554432,
"maxDimension": 4096,
"maxPixels": 8847360,
"unplayableMimes": ["image/webp"]
  },
  "video": { "maxBytes": 33554432 },
  "playlist": { "maxItems": 500 }
}
```

> **上傳照片前先讀這一段**
>
> `maxPixels` 是 4096 × 2160 = 8,847,360，也是最常讓人意外的限制：手機
> 相機直出的照片通常是 4032 × 3024 = 1220 萬像素 — 兩邊都*沒超過*
> `maxDimension`，卻仍然被拒絕，`reason: "image_too_many_pixels"`。
> 位元組上限反而很少是你撞到的那一個。

**伺服器不做縮圖。** 儲存下來的位元組就是去重雜湊的依據，任何轉換都會
破壞內容定址。縮圖是你的工作 — 在瀏覽器裡六行就夠：

```js
const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
const scale = Math.min(
  1,
  4096 / Math.max(bmp.width, bmp.height),
  Math.sqrt(8847360 / (bmp.width * bmp.height)),
);
const canvas = Object.assign(document.createElement("canvas"), {
  width: Math.floor(bmp.width * scale),
  height: Math.floor(bmp.height * scale),
});
canvas.getContext("2d").drawImage(bmp, 0, 0, canvas.width, canvas.height);
const blob = await new Promise((r) => canvas.toBlob(r, "image/jpeg", 0.9));
```

`imageOrientation: "from-image"` 很重要：少了它，直式照片會在錯的軸上
被量測，然後又被渲染端再旋轉一次。

## 螢幕

### `GET /screens`

```jsonc
{ "screens": [ {
  "id": "…", "name": "Lobby", "site": "HQ",
  "lastSeenAt": 1756400000000,
  "connectivity": "online",           // online | offline | never-seen
  "state": "converged",               // converged | syncing | no-content | unknown
  "converged": true,
  "content": { "layer": "takeover", "kind": "asset", "assetId": "…", "name": "menu.png" },
  "layers": [ /* 完整堆疊，由高至低 */ ]
} ] }
```

**要下結論，就看 `converged` 這個欄位。** 只有在螢幕正與我們通訊、有
內容可顯示、且回報的版本正是我們最後交代的那一版時，它才是 true —
伺服器送出去了不算。`content` 是堆疊的最上層。停止回報的螢幕會變成
`unknown`，不管它最後說了什麼。

`GET /screens/:screenId` 回傳單一螢幕，附上線狀態細節和裝置最後回報的
自身狀態。

### `PUT /screens/:screenId/layers/:layer`

```jsonc
{ "playlistId": "…" }                        // 輪播一份播放清單
{ "assetId": "…", "durationMs": 15000 }      // 用單一項目固定螢幕
```

兩者只能擇一；`:layer` 是上面五層之一。播放清單必須存在；資產必須已
上傳完成 — 螢幕被指向還不存在的位元組，會一直認定檔案缺失、永遠停在
準備中。

### `DELETE /screens/:screenId/layers/:layer`

移除一層；底下的內容透出來。清除一個本來就空的圖層也算成功：你要它
消失，它就是消失了。

### `DELETE /screens/:screenId/layers`

清空整個堆疊 — 「讓這個螢幕變空白」的直接手段。

### `POST /screens/:screenId/commands`

```jsonc
{ "type": "refetch" }      // 成員：立即重新同步
{ "type": "screenshot" }   // 成員：擷取面板上的畫面
{ "type": "reboot" }       // 管理員
```

指令搭螢幕下一次回報的便車送達，執行結果也會回報回來。`refetch` 是修復
工具，針對裝置持有錯誤位元組的情況，不是快速通道 — 平常發布的內容會在
螢幕自己的回報中送達。

## 上傳內容

### `POST /content?name=<filename>[&durationMs=<ms>]`

請求本體**就是**檔案；型別由 `Content-Type` 標頭決定。

```sh
curl -X POST "$BASE/api/v1/content?name=menu.png" \
  -H "authorization: Bearer $TOKEN" \
  -H "content-type: image/png" \
  --data-binary @menu.png
```

```jsonc
{
  "assetId": "…", "sha256": "…", "kind": "image", "bytes": 20480,
  "width": 1920, "height": 1080, "deduped": false
}
```

`width` 和 `height` 是伺服器量出來的，量不出來時**就是 null** — 檔案
照樣會儲存，但這裡的 null 是在提醒你：把螢幕指向它，畫面上不會出現
任何東西。指向之前先驗證這兩個欄位。

**重複上傳相同的位元組是個 no-op**，回應 `200` 和既有的那筆資產
（`deduped: true`），兩個推送互相競速時也一樣。這是刻意的，因為
連接器最自然的形狀就是「每次都把全部推一遍」。

回應中的 sha256 是我們對實際儲存的位元組算出來的；圖片防護（最長邊、
總像素）依據檔案自己的 PNG/JPEG/GIF 標頭執行 — 電視面板解不了的圖片
會在門口被拒絕，而不是拖垮一個螢幕。

限制：每個請求 32 MB、只收圖片和影片、不收 WebP — 它上傳沒問題、
同步沒問題，就是永遠畫不上面板。

**超過 32 MB 請走分段上傳**：`POST /assets` 帶檔案的 sha256、mime、位元組數
和種類，回應一個 `assetId`；`PUT /assets/:assetId/parts/:n` 接收每段最多
10 MB 的分段，各回傳一個 etag；`POST /assets/:assetId/complete` 帶上所有
分段完成上傳，`POST /assets/:assetId/abort` 放棄它。主控台傳影片走的就是
這條路，因為瀏覽器需要在場地的 wifi 上續傳；同樣的理由，它也對你開放。
這裡的 sha256 由你申報 — 謊報只會弄髒你自己的媒體庫，因為裝置下載時會
驗證。

### `GET /assets`、`GET /assets/:assetId/content`

組織裡就緒的資產；後者回傳位元組，支援 Range。

### `PATCH /assets/:assetId`

```jsonc
{ "name": "lobby-final.png" }
```

只影響顯示名稱。身分是 sha256，所以改名不搬動任何位元組，也不會讓
螢幕持有的東西失效。

### `DELETE /assets/:assetId`

給那些按排程產生內容、否則也會按排程堆出媒體庫項目的整合程式用的。
有東西還持有這筆資產時回應 `409` 而不刪除，並指名是誰：

| `reason` | 意思 |
|---|---|
| `asset_still_uploading` | 有一筆分段上傳擁有這列；請改為中止那筆上傳。 |
| `asset_in_playlists` | 先把它從指名的播放清單移除。 |
| `asset_on_screens` | 有螢幕正在顯示它。先清除或替換那個螢幕的圖層。 |

最後一條不是客套：刪掉螢幕正指向的位元組，那台裝置會永遠認定檔案
缺失，再也無法同步完成。

## 播放清單

`GET /playlists` 列出全部。`GET /playlists/:playlistId` 回傳一份與其
項目，讓你可以對照自己上次寫入的內容，不必自己保存一份我們的狀態。

```jsonc
{
  "playlist": { "id": "…", "name": "Photo wall" },
  "items": [ {
"id": "…", "position": 0, "assetId": "…", "sha256": "…",
"kind": "image", "name": "guest-0042.jpg", "durationMs": 5000
  } ]
}
```

`POST /playlists` 帶 `{ "name": "…" }` 回應 `201` 和 `{ "playlistId" }`。
`PATCH /playlists/:playlistId` 改名。`DELETE /playlists/:playlistId`
刪除一份；若有螢幕被指定了它，回應 `409 playlist_on_screens` 並列出
那些螢幕 — 刪除牆上正在播的東西，必須是明確的兩步。

### `PUT /playlists/:playlistId/items`

```jsonc
{ "items": [
  { "assetId": "…", "durationMs": 5000 },
  { "assetId": "…" }
] }
```

**這會取代整份清單**，而這正是特點：送出你想要的清單，永遠不必計算
差異。重送一份沒變的清單無害。順序就是陣列的順序。`durationMs` 可以
省略 — 影片用自己的長度，靜態圖有預設值。最多 `playlist.maxItems` 筆。

項目指向不存在或還沒上傳完成的資產時，會以
`playlist_item_asset_not_found` / `playlist_item_asset_not_ready` 拒絕，
指名該 `assetId`，且什麼都不會寫入。

## 速率限制

兩個額度，刻意計量不同的東西。

| 額度 | 依據 | 大小 | 429 的 `reason` |
|---|---|---|---|
| 帶權杖並通過驗證的請求 | 你的權杖 | 120 / 分鐘 | `too_many_api_requests` |
| 驗證失敗的請求 | 你的位址 | 20 / 分鐘 | `too_many_attempts` |

**要配速的是第一個。** 它以權杖計量，所以某個程式失控的迴圈花不掉別人
的額度，而且它只對權杖計費 — 程式不會佔掉主控台操作人員的額度，反過來
也一樣。**第二個只對拒絕計費** — 帶著有效權杖的請求永遠碰不到它；
實務上只有部署了被撤銷或打錯的權杖又拚命重試才會遇到。

兩者都以分鐘計，並按服務節點分別計數，所以把它們當成該退避的天花板，
不是要花好花滿的額度。429 帶著和其他拒絕相同的格式；退避後重試即可 —
兩個額度都不是封鎖。

## 目前還做不到的事

寫明白，免得有人以為是忘了：

- **沒有依螢幕或依圖層的授權範圍。** 權杖作用於整個組織，任何圖層都能
  寫；「各自擁有一層」目前是大家守的慣例，不是伺服器強制的規則。
- **沒有租約。** 兩個程式寫同一層就是後寫的贏 — 所以這裡的每一筆寫入
  都會被稽核。
- **權杖沒有效期。** 控制手段是撤銷，`lastUsedAt` 讓被遺忘的權杖在
  主控台現形。
- **沒有對外 webhook**、沒有播放證明匯出、沒有 SSO。
- **429 沒有 `Retry-After`。** 兩個額度都以分鐘計，等一分鐘一定夠。
- **伺服器不做縮圖** — 見上面的限制。這是決定，不是缺漏。
- **沒有推送**：內容在螢幕的一次回報內送達（大約 20 秒），不是即時。
- **沒有 SDK。** 一半的路由一句 `curl` 就打完；其餘的收一個 JSON 本體。

## 稽核紀錄

上面的每一筆寫入 — 不論出自人或程式 — 都會以行為者**當時的名字**記錄，
所以撤銷或改名都改寫不了歷史。用 `GET /audit` 讀取，或在主控台的
**團隊 → 活動** 查看。當兩個寫入者共用一個螢幕，讓共用變得安全的
就是這份紀錄。

Source: https://anysign.tv/zh-tw/docs/api/index.mdx
