一個 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 用來衡量請求的上限值。用讀的,不要寫死 — 這些數值可以調高, 你這邊不必改版。
{
"upload": { "maxBytes": 33554432 },
"image": {
"maxBytes": 33554432,
"maxDimension": 4096,
"maxPixels": 8847360,
"unplayableMimes": ["image/webp"]
},
"video": { "maxBytes": 33554432 },
"playlist": { "maxItems": 500 }
}伺服器不做縮圖。 儲存下來的位元組就是去重雜湊的依據,任何轉換都會 破壞內容定址。縮圖是你的工作 — 在瀏覽器裡六行就夠:
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
{ "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
{ "playlistId": "…" } // 輪播一份播放清單
{ "assetId": "…", "durationMs": 15000 } // 用單一項目固定螢幕兩者只能擇一;:layer 是上面五層之一。播放清單必須存在;資產必須已
上傳完成 — 螢幕被指向還不存在的位元組,會一直認定檔案缺失、永遠停在
準備中。
DELETE /screens/:screenId/layers/:layer
移除一層;底下的內容透出來。清除一個本來就空的圖層也算成功:你要它 消失,它就是消失了。
DELETE /screens/:screenId/layers
清空整個堆疊 — 「讓這個螢幕變空白」的直接手段。
POST /screens/:screenId/commands
{ "type": "refetch" } // 成員:立即重新同步
{ "type": "screenshot" } // 成員:擷取面板上的畫面
{ "type": "reboot" } // 管理員指令搭螢幕下一次回報的便車送達,執行結果也會回報回來。refetch 是修復
工具,針對裝置持有錯誤位元組的情況,不是快速通道 — 平常發布的內容會在
螢幕自己的回報中送達。
上傳內容
POST /content?name=<filename>[&durationMs=<ms>]
請求本體就是檔案;型別由 Content-Type 標頭決定。
curl -X POST "$BASE/api/v1/content?name=menu.png" \
-H "authorization: Bearer $TOKEN" \
-H "content-type: image/png" \
--data-binary @menu.png{
"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
{ "name": "lobby-final.png" }只影響顯示名稱。身分是 sha256,所以改名不搬動任何位元組,也不會讓 螢幕持有的東西失效。
DELETE /assets/:assetId
給那些按排程產生內容、否則也會按排程堆出媒體庫項目的整合程式用的。
有東西還持有這筆資產時回應 409 而不刪除,並指名是誰:
reason |
意思 |
|---|---|
asset_still_uploading |
有一筆分段上傳擁有這列;請改為中止那筆上傳。 |
asset_in_playlists |
先把它從指名的播放清單移除。 |
asset_on_screens |
有螢幕正在顯示它。先清除或替換那個螢幕的圖層。 |
最後一條不是客套:刪掉螢幕正指向的位元組,那台裝置會永遠認定檔案 缺失,再也無法同步完成。
播放清單
GET /playlists 列出全部。GET /playlists/:playlistId 回傳一份與其
項目,讓你可以對照自己上次寫入的內容,不必自己保存一份我們的狀態。
{
"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
{ "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 讀取,或在主控台的
團隊 → 活動 查看。當兩個寫入者共用一個螢幕,讓共用變得安全的
就是這份紀錄。