一個 API,兩種憑證。主控台和任何程式一樣,都是這個 API 的使用者:
人用 session cookie,程式用 bearer 權杖,背後是同一組路由。所有路徑都在
/api/v1 底下;這一頁是我們建議整合程式依賴的精選子集。
取得權杖
以管理員身分到 團隊 → API 權杖 → 新增權杖。密鑰只顯示一次,系統只 保存它的雜湊值。撤銷立即生效。
權杖擁有成員的權限 — 它改變螢幕顯示什麼,永遠不動螢幕本身。它不能
認領螢幕、重新開機、重新設定螢幕、邀請任何人,也不能再核發權杖;所有
標示管理員的操作一律回應 403。
每個請求都帶上它:
Authorization: Bearer anysign_<64 hex characters>錯誤在所有地方都用同一種格式:機器可讀的 reason、一句英文 message,
以及在句子提到限制數值時附上的 params。
權杖也有存取範圍:它可以查看與更改的群組,在建立時指定
(POST /tokens { name, scope? },其中 scope 是
{ groupIds? } 或 null),之後可以更改
(PUT /tokens/:tokenId/scope { scope },管理員限定——權杖不能放寬自己
的視野,管理員也只能更改視野落在自己視野之內的權杖)。沒有任何授權的
權杖作用於整個組織;GET /tokens 列出每個權杖的 scope,沒有授權的是
null。這個請求主體取代了第一版頂層的 groupIds / screenIds 欄位,
其中 screenIds 已經取消:只需要一面螢幕的權杖,就給它一個只有那面
螢幕的群組。受限的權杖只看得到它持有的群組、以及那些群組底下的群組
中的螢幕,其他每一面螢幕的路由都回 404。樹是即時讀取的,所以螢幕
被移進受限權杖持有的群組後,下一次請求就會進入它的視野。
模型:一個內容槽,一棵群組樹
一面螢幕正好顯示一樣東西:一個輪播的播放清單,或是單一素材。那就是它
的內容槽,對它的每一次寫入就是全部——底下沒有別的東西,也沒有移除
後會透出來的層級。最後寫入的勝出:某人手動設定一面螢幕之後一分鐘,你
往群組推送,那面螢幕就會改變;再過一分鐘往那面螢幕推送,它又會變回來。
每一次寫入都會連同它取代了什麼一起留在稽核紀錄裡,
GET /screens/:screenId/history 可以讀出螢幕最近顯示過的內容。
螢幕活在一棵群組樹裡,每個組織有一個根,以組織命名。一面螢幕正好屬於 一個群組;在「Lobby」裡就是在「Branch A」裡。在群組上顯示內容,會顯示在 它底下的每一面螢幕,而且群組會記住它,所以之後移進來的螢幕也會跟著顯示。
存取範圍
一個主體——不論是成員或權杖——對螢幕牆的視野可以限縮到部分群組。沒有任何授權的主體持有根,也就是整個組織。持有一個群組,就是持有它底下的一切:受限的主體只看得到自己持有的群組、以及那些群組底下的群組中的螢幕;其餘螢幕的每一條路由都回答 404。
其他規則都由一條規則推出:**群組只有在被持有、或位於被持有的群組底下時,才在視野內。**在自己持有的群組底下建立群組不需要另外授權,因為上層已經包含它了。認領不會授予任何東西:受限的主體必須指定一個自己持有的群組(422 claim_group_required),組織的管理員可以不指定,螢幕就放在根。編輯者只能轉授自己持有的東西,永遠不能授予整個組織(403 scope_cannot_widen);也只能變更、限縮、移除或撤銷視野完全落在自己視野之內的主體(403 outside_scope)。
角色不受存取範圍影響。線路上的 scope 是 { groupIds },null 代表整個組織;清單為空代表無存取權限——當某人唯一持有的群組被刪除時就會如此(授權隨群組消失,不會放寬成整個組織)。在 groupIds 裡放入根的 id 代表整個組織,會被正規化為 null。所有目標都已刪除的邀請在接受時會被拒絕(409 invite_scope_stale)並保持待處理。媒體庫仍是全組織共用;刪除防護仍以整個組織為準,但只會點名你看得到的螢幕。離線通知郵件與稽核紀錄都依讀者的視野過濾。
限制
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",
"group": { "id": "…", "name": "Lobby", "path": "Branch A / Lobby" },
"lastSeenAt": 1756400000000,
"connectivity": "online", // online | offline | never-seen
"state": "converged", // converged | syncing | no-content | unknown
"converged": true,
"revoked": false,
"content": { "kind": "playlist", "playlistId": "…", "name": "Lobby loop" }
} ] }要下結論,就看 converged 這個欄位。 只有在螢幕正與我們通訊、有
內容可顯示、且回報的版本正是我們最後交代的那一版時,它才是 true —
伺服器送出去了不算。content 是那面螢幕的內容槽,沒有內容時為 null;
group 指出螢幕所屬的那一個群組。停止回報的螢幕會變成 unknown,不管
它最後說了什麼。
GET /screens/:screenId 回傳單一螢幕,附上線狀態細節和裝置最後回報的
自身狀態。
PUT /screens/:screenId/content
{ "playlistId": "…" } // 輪播一個播放清單
{ "assetId": "…", "durationMs": 15000 } // 用單一素材佔住螢幕兩者只能擇一。播放清單必須存在,素材必須已經上傳完成——指向還沒到位的 位元組,螢幕會把它算成缺檔,然後一直停在準備中。
DELETE /screens/:screenId/content
螢幕不再顯示任何東西。清除一個本來就空的內容槽也算成功:你要它消失, 而它就是不在了。
POST /screens/content
同樣的主體再加上 screenIds: […](最多 1000):對一組螢幕的單一動作,
回覆 { ok, screens, cause }。POST /audit/:cause/undo 會把這次動作影響
到的每一面螢幕,依各自紀錄的先前內容還原。
GET /screens/:screenId/history
這面螢幕最近顯示過的內容,去除重複後最多 20 筆,由新到舊;播放清單或
素材若已被刪除,會附上 available: false。
POST /screens/:screenId/commands
{ "type": "refetch" } // 成員:立即重新同步
{ "type": "screenshot" } // 成員:擷取面板上的畫面
{ "type": "reboot" } // 管理員指令搭螢幕下一次回報的便車送達,執行結果也會回報回來。refetch 是修復
工具,針對裝置持有錯誤位元組的情況,不是快速通道 — 平常發布的內容會在
螢幕自己的回報中送達。
群組
群組構成一棵樹,每個組織有一個根,以組織命名。一面螢幕正好屬於一個群組;在「Lobby」裡就是在「Branch A」裡。群組上掛著兩樣東西:下面說的內容,以及存取權——持有某個群組的人或權杖,看得到它底下的一切;詳見上面的〈存取範圍〉。
GET /groups 依閱讀順序列出你視野中的群組:
{ "groups": [ {
"id": "…", "name": "Lobby", "parentId": "…", "path": "Branch A / Lobby", "depth": 2,
"createdAt": 1756400000000,
"screenIds": ["…"], // 直接屬於這個群組的螢幕
"content": { "kind": "playlist", "playlistId": "…", "name": "Lobby loop" },
"grantees": 2, "soleHolders": 1
} ] }根的 parentId 是 null,id 就是組織的 id。樹的形狀由管理員決定:POST /groups { name, parentId }(同一個上層底下名稱唯一,重複回 409 group_name_taken)、PATCH /groups/:groupId { name }、POST /groups/:groupId/move { parentId }(不能移到自己底下,回 409 group_cycle)、DELETE /groups/:groupId — 裡面的螢幕和子群組會移到上一層群組;指名它的每一筆授權都會消失,因此變得什麼都沒有的人會停留在無存取權限,而不是被放寬到整個組織(根回 409 group_root,自己存取權所依附的群組回 409 group_is_grant_root)。POST /screens/move { screenIds, groupId } 把螢幕放進群組;目的地必須在你的視野內,所以移動永遠不會離開它。
群組上的內容和螢幕上的內容一樣是成員權限:PUT /groups/:groupId/content 接受與螢幕相同的主體,顯示在群組底下的每一面螢幕,並由該群組與其底下的每個群組記住;DELETE /groups/:groupId/content 則全部清除。兩者都回覆 { ok, screens, cause }。GET /groups/:groupId/history 讀出該群組最近的推送。
群組的內容是記憶,不是權威:解析器只讀螢幕自己的內容槽。被移進有內容的群組的螢幕會跟著顯示;移進空群組的螢幕,維持原本顯示的內容。
POST /screens/claim 接受 groupId;存取範圍受限的呼叫者必須指定一個自己持有的群組,組織的管理員可以省略,螢幕就放在根。
播放清單與素材的刪除防護,只要任何一面螢幕的內容槽、或任何一個群組的記憶還持有它,就會拒絕。
上傳內容
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 |
有螢幕正在顯示它。先清除或替換那個螢幕的內容槽。 |
最後一條不是客套:刪掉螢幕正指向的位元組,那台裝置會永遠認定檔案 缺失,再也無法同步完成。
範本與資料來源
範本是像圖片一樣上傳的 HTML 檔:以 content-type: text/html 送到
POST /content,最大 4 MB,一個檔案自給自足——螢幕在沒有網路的畫面中繪製它,
所以字型、圖示與圖片以 data URI 內嵌,影片則不放進來。回應為
kind: "html"。檔案內的資訊區塊宣告它讀取的資料,以及主控台預覽用的範例:
<script type="application/anysign+json">
{ "name": "Queue board", "sources": ["queue"],
"sample": { "queue": { "serving": [{ "number": "A042", "room": "Room 1" }] } } }
</script>範本讀取 anysign.data.queue,並以 anysign.onData(cb) 接收變更。
anysign.data.screen 一定存在({ id, name, group: { id, name, path } }),
所以一個範本讀出自己在哪個螢幕上,就能服務所有螢幕;
anysign.data.__meta.queue 提供 { version, updatedAt, kind, stale }。
資料來源是範本依名稱接收的 JSON。三種類型:manual(在主控台輸入)、
push(由你的程式寫入)、fetch(由服務定期從網址讀取)。值最多 64 KB
的 UTF-8——中文或日文看板約為英文字元數的三分之一,
每個組織最多 50 個來源,名稱須符合 ^[a-z][a-z0-9_-]{0,63}$ 且不可為 screen。
PUT /data/:name
推送。請求本文就是完整的值:
curl -X PUT "$BASE/api/v1/data/queue" \
-H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{"serving":[{"number":"A042","room":"Room 1"}],"waiting":["A043"]}'{ "ok": true, "version": 1042 }所有正在顯示引用 queue 之範本的螢幕會在約一秒內收到通知,文件隨之改變,
因此 GET /screens 的 converged 就代表「正在顯示這個值」。只有 push
類型接受 PUT;manual 或 fetch 會回 409 data_source_not_pushable。
推送有自己的額度——每個權杖每分鐘 600 次,與下方的每分鐘 120 次分開——
所以每次叫號都推送的系統不會拖垮主控台,反之亦然。
GET /data、POST /data、GET|PATCH|DELETE /data/:name
列出、建立、讀取、設定、刪除。POST /data 接受 { "name", "kind": "push", "config": { "staleAfterSec": 120 } }、{ "name", "kind": "manual", "value": {...} } 或 { "name", "kind": "fetch", "config": { "url", "refreshSec": 300, "headers": { "x-api-key": "…" } } }。標頭的值不會回傳,只回傳名稱。擷取型
來源的 url 僅接受 http: 或 https:。擷取型來源最多每分鐘讀一次;超過
64 KB 的內容會直接拒絕而不會先讀進來,讀取失敗只會更新 lastError,兩次更新
間隔內沒有成功讀取則 stale 變為 true。
播放清單
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 帶著和其他拒絕相同的格式;退避後重試即可 — 兩個額度都不是封鎖。
目前還做不到的事
寫明白,免得有人以為是忘了:
- 沒有受保護的緊急訊息。 往根推送會送到每一面螢幕,之後對任何一面螢幕
的推送在那面螢幕上勝出 — 所以這裡的每一筆寫入都會被稽核,而對某次推送的
cause執行撤銷,就是回頭的路。 - 權杖沒有效期。 控制手段是撤銷,
lastUsedAt讓被遺忘的權杖在 主控台現形。 - 沒有對外 webhook、沒有播放證明匯出、沒有 SSO。
- 429 沒有
Retry-After。 兩個額度都以分鐘計,等一分鐘一定夠。 - 伺服器不做縮圖 — 見上面的限制。這是決定,不是缺漏。
- 沒有推送:內容在螢幕的一次回報內送達(大約 20 秒),不是即時。
- 沒有 SDK。 一半的路由一句
curl就打完;其餘的收一個 JSON 本體。 - 範本需要支援它的播放器版本。 螢幕的文件今天起就會帶著
html項目與其資料;繪製它的畫面已完成、等待發布。不支援的播放器會略過該項目。
稽核紀錄
上面的每一筆寫入 — 不論出自人或程式 — 都會以行為者當時的名字記錄,
所以撤銷或改名都改寫不了歷史。用 GET /audit 讀取,或在主控台的
團隊 → 活動 查看。當兩個寫入者共用一個螢幕,讓共用變得安全的
就是這份紀錄。