跳到主要內容

公開 API

用你自己的軟體把內容送上螢幕 — 權杖、群組樹、單一內容槽、端點、限制與稽核紀錄。

更新於 以 Markdown 檢視

一個 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 讀取,或在主控台的 團隊 → 活動 查看。當兩個寫入者共用一個螢幕,讓共用變得安全的 就是這份紀錄。

Navigation

輸入關鍵字開始搜尋…

↑↓ navigate↵ selectEsc close