screentinker/docs/openapi.yaml
ScreenTinker 6471c503ab Default a video playlist item to the clip's own length (#237)
Adding a 32s video gave it the flat 10s default, so it was cut off mid-play
unless the operator looked up the runtime and typed it — per item, every time.
The content row already carries the probed duration; it now becomes the default.

The rule lives in one place (lib/item-duration.js) because the operator sees one
product, not six insert paths: playlist add, assign-to-display, group assign,
agency portal, content-only schedule, and the public API all share it. Only the
playlist route defaulted before, and it stored the raw probe (31.7) which the
Android player's optInt read silently truncated back to 31.

Explicit values always win. Content with no trustworthy duration (image, widget,
YouTube, remote URL, failed probe) keeps the 10s default, and a duration that is
0/negative/NaN or absurd (> 12h, i.e. a broken probe) falls back rather than
reaching a device — a 0 makes the players schedule a 0ms advance, which self-loops
and black-screens the TV.

Dashboard: the add-item picker shows a clip's length, the assign-to-display modal
pre-fills the duration field from the selected clip (never overwriting a value the
operator typed), and onboarding stops hardcoding 10 on the first assignment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:36:53 -05:00

1877 lines
61 KiB
YAML

openapi: 3.1.0
info:
title: ScreenTinker Public API
version: 1.9.29
description: |
Public, token-scoped REST API for ScreenTinker digital signage.
## Authentication
Every endpoint (except the two unauthenticated device-render endpoints) accepts a
**scoped personal access token** presented as `Authorization: Bearer st_...`. The
dashboard's session JWT is also accepted, but this contract targets token consumers.
## Scope ladder (read &#60; write &#60; full)
Tokens carry one of three scopes; higher scopes subsume lower ones:
- `read` — all `GET` endpoints.
- `write` — all resource mutations (`POST` / `PUT` / `PATCH` / `DELETE`).
- `full` — operational fleet commands (currently only
`POST /groups/{id}/command`: reboot / shutdown / screen on/off).
Each operation advertises its minimum scope via the `x-required-scope` extension
and restates it in the operation description. A token with insufficient scope is
rejected with `403`.
All resources are scoped to the token's workspace; cross-workspace access is not
available to tokens.
servers:
- url: /api
tags:
- name: content
description: Media library items (uploads, remote URLs, YouTube).
- name: folders
description: Content folder hierarchy.
- name: playlists
description: Playlists and their items / per-item schedules.
- name: assignments
description: Device playlist items (per-device assignment helpers).
- name: devices
description: Registered display devices.
- name: layouts
description: Multi-zone screen layouts and zones.
- name: groups
description: Device groups and bulk operations.
- name: schedules
description: Time-based content scheduling.
- name: walls
description: Video walls (multi-device grids).
- name: reports
description: Proof-of-play and uptime reporting.
- name: widgets
description: Dynamic widgets (clock, weather, RSS, etc.) and their render output.
- name: activity
description: Activity log.
- name: kiosk
description: Interactive kiosk pages and their render output.
- name: pip
description: Picture-in-picture / overlay push (image or web on top of the running playlist).
security:
- ApiToken: []
- SessionJWT: []
components:
securitySchemes:
ApiToken:
type: http
scheme: bearer
bearerFormat: "st_<token>"
description: |
Scoped personal access token. Send as `Authorization: Bearer st_...`.
The token's scope (`read`, `write`, or `full`) gates which operations
it may call (see the scope ladder in the API description).
SessionJWT:
type: http
scheme: bearer
description: |
Dashboard session JWT. Also accepted on every authenticated endpoint,
but this spec targets scoped-token consumers.
schemas:
Error:
type: object
properties:
error:
type: string
Device:
type: object
properties:
id:
type: string
name:
type: string
status:
type: string
workspace_id:
type: [string, "null"]
playlist_id:
type: [string, "null"]
layout_id:
type: [string, "null"]
description: Assigned layout (null = fullscreen). Recently added.
timezone:
type: string
orientation:
type: string
notes:
type: string
created_at:
type: integer
# --- Network addresses -------------------------------------------------------------
# Two different addresses, easy to confuse, so both are spelled out. They answer
# different questions and either can be null.
ip_address:
type: [string, "null"]
description: |
The device's **public (WAN)** address as observed by the server when the player
connected — i.e. what the internet sees. Behind a reverse proxy this is taken from
the first `X-Forwarded-For` entry, otherwise the socket peer address. Every device
on one site normally shares this. Null until the device has connected at least once.
local_ip:
type: [string, "null"]
description: |
The device's **own address on its local network** (e.g. `192.168.1.42`), as
reported by the player itself. This is the one to use to reach a panel directly on
site. Null on players that do not report it, or where the platform withholds it.
# --- Latest telemetry --------------------------------------------------------------
# Flattened from the most recent telemetry report. All null for a device that has
# never reported, and for platforms that cannot supply a given metric — treat every
# field here as optional rather than assuming a web player reports what Android does.
wifi_ssid:
type: [string, "null"]
description: |
Wi-Fi network name, or null on a wired/unknown connection.
Special value `"permission"` means the device is on Wi-Fi but the operating system
withheld the name — on Android 10+ reading the SSID requires a location permission
that ScreenTinker only asks for if an operator opts in. Treat `"permission"` as
"connected, name unavailable", not as a network literally called that.
wifi_rssi:
type: [integer, "null"]
description: Signal strength in dBm (negative; closer to zero is stronger).
battery_level:
type: [integer, "null"]
description: Battery percentage 0-100, or null on mains-powered hardware.
battery_charging:
type: [integer, "null"]
description: 1 charging, 0 not charging, null unknown.
storage_free_mb:
type: [integer, "null"]
storage_total_mb:
type: [integer, "null"]
ram_free_mb:
type: [integer, "null"]
ram_total_mb:
type: [integer, "null"]
cpu_usage:
type: [number, "null"]
description: Recent CPU utilisation as a percentage, where the platform exposes it.
uptime_seconds:
type: [integer, "null"]
description: Seconds since the device booted.
Playlist:
type: object
properties:
id:
type: string
name:
type: string
description:
type: string
status:
type: string
description: draft | published
workspace_id:
type: [string, "null"]
item_count:
type: integer
display_count:
type: integer
PlaylistItem:
type: object
properties:
id:
type: integer
playlist_id:
type: string
content_id:
type: [string, "null"]
widget_id:
type: [string, "null"]
zone_id:
type: [string, "null"]
description: Optional layout-zone placement. Recently added.
sort_order:
type: integer
duration_sec:
type: integer
Content:
type: object
properties:
id:
type: string
filename:
type: string
mime_type:
type: string
file_size:
type: integer
duration_sec:
type: [number, "null"]
remote_url:
type: [string, "null"]
thumbnail_path:
type: [string, "null"]
folder_id:
type: [string, "null"]
workspace_id:
type: [string, "null"]
Layout:
type: object
properties:
id:
type: string
name:
type: string
width:
type: integer
height:
type: integer
is_template:
type: integer
workspace_id:
type: [string, "null"]
zones:
type: array
items:
$ref: '#/components/schemas/Zone'
Zone:
type: object
properties:
id:
type: string
layout_id:
type: string
name:
type: string
x_percent:
type: number
y_percent:
type: number
width_percent:
type: number
height_percent:
type: number
z_index:
type: integer
zone_type:
type: string
fit_mode:
type: string
background_color:
type: string
Widget:
type: object
properties:
id:
type: string
widget_type:
type: string
description: clock | weather | rss | text | webpage | social | directory-board | directory-search
name:
type: string
config:
type: object
workspace_id:
type: [string, "null"]
Group:
type: object
properties:
id:
type: string
name:
type: string
color:
type: string
description: "#RRGGBB"
playlist_id:
type: [string, "null"]
device_count:
type: integer
workspace_id:
type: [string, "null"]
Schedule:
type: object
properties:
id:
type: string
device_id:
type: [string, "null"]
group_id:
type: [string, "null"]
zone_id:
type: [string, "null"]
content_id:
type: [string, "null"]
widget_id:
type: [string, "null"]
layout_id:
type: [string, "null"]
playlist_id:
type: [string, "null"]
title:
type: string
start_time:
type: string
end_time:
type: string
timezone:
type: string
recurrence:
type: [string, "null"]
priority:
type: integer
enabled:
type: integer
parameters:
Limit:
name: limit
in: query
schema:
type: integer
description: Max rows to return.
Offset:
name: offset
in: query
schema:
type: integer
description: Row offset for pagination.
paths:
# ------------------------------------------------------------------ content
/content:
get:
tags: [content]
summary: List content in the current workspace
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- name: folder
in: query
schema: { type: string }
- name: folder_id
in: query
schema: { type: string }
description: '"root"/"" = root-level only; UUID = that folder.'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: Array of content items.
post:
tags: [content]
summary: Upload a content file (multipart)
description: 'Requires scope: write.'
x-required-scope: write
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
'201': { description: Created content item. }
/content/folders:
get:
tags: [content]
summary: List distinct content folder names with counts
description: 'Requires scope: read.'
x-required-scope: read
responses:
'200':
description: 'Array of { folder, count }.'
/content/remote:
post:
tags: [content]
summary: Add a remote-URL content item
description: 'Requires scope: write. SSRF-gated (http/https, no internal hosts).'
x-required-scope: write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [url]
properties:
url: { type: string }
name: { type: string }
mime_type: { type: string }
responses:
'201': { description: Created content item. }
/content/youtube:
post:
tags: [content]
summary: Add a YouTube video as content
description: 'Requires scope: write.'
x-required-scope: write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [url]
properties:
url: { type: string }
name: { type: string }
responses:
'201': { description: Created content item. }
/content/{id}:
get:
tags: [content]
summary: Get content metadata
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Content item. }
'404': { description: Not found. }
put:
tags: [content]
summary: Update content metadata
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
filename: { type: string }
mime_type: { type: string }
remote_url: { type: string }
folder: { type: string }
folder_id: { type: string }
responses:
'200': { description: Updated content item. }
delete:
tags: [content]
summary: Delete content
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success, affectedDevices }.' }
/content/{id}/replace:
put:
tags: [content]
summary: Replace the content file (multipart)
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file: { type: string, format: binary }
responses:
'200': { description: Updated content item. }
/content/{id}/file:
get:
tags: [content]
summary: Download the content file
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Binary file stream. }
/content/{id}/thumbnail:
get:
tags: [content]
summary: Download the content thumbnail
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Binary image stream. }
# ------------------------------------------------------------------ folders
/folders:
get:
tags: [folders]
summary: List content folders in the current workspace
description: 'Requires scope: read.'
x-required-scope: read
responses:
'200': { description: Array of folders. }
post:
tags: [folders]
summary: Create a folder
description: 'Requires scope: write.'
x-required-scope: write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
parent_id: { type: string }
responses:
'201': { description: Created folder. }
/folders/{id}:
put:
tags: [folders]
summary: Rename or move a folder
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
parent_id: { type: [string, "null"] }
responses:
'200': { description: Updated folder. }
delete:
tags: [folders]
summary: Delete a folder
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success }.' }
# ---------------------------------------------------------------- playlists
/playlists:
get:
tags: [playlists]
summary: List playlists in the current workspace
description: 'Requires scope: read.'
x-required-scope: read
responses:
'200': { description: Array of playlists. }
post:
tags: [playlists]
summary: Create a playlist
description: 'Requires scope: write.'
x-required-scope: write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
description: { type: string }
responses:
'201': { description: Created playlist. }
/playlists/{id}:
get:
tags: [playlists]
summary: Get a playlist with its items
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Playlist with items. }
put:
tags: [playlists]
summary: Update playlist name/description
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
description: { type: string }
responses:
'200': { description: Updated playlist. }
delete:
tags: [playlists]
summary: Delete a playlist
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success }.' }
/playlists/{id}/publish:
post:
tags: [playlists]
summary: Publish a playlist (snapshot + push to devices)
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Published playlist with items. }
/playlists/{id}/discard:
post:
tags: [playlists]
summary: Discard draft changes (revert to published snapshot)
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Reverted playlist with items. }
/playlists/{id}/items:
get:
tags: [playlists]
summary: List a playlist's items
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Array of playlist items. }
post:
tags: [playlists]
summary: Add a content or widget item to a playlist
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
content_id: { type: string }
widget_id: { type: string }
zone_id: { type: string }
sort_order: { type: integer }
duration_sec:
type: integer
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
responses:
'201': { description: Created playlist item. }
/playlists/{id}/items/reorder:
post:
tags: [playlists]
summary: Reorder playlist items
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [order]
properties:
order:
type: array
items: { type: integer }
responses:
'200': { description: Reordered items. }
/playlists/{id}/items/{itemId}:
put:
tags: [playlists]
summary: Update a playlist item
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: itemId, in: path, required: true, schema: { type: integer } }
requestBody:
content:
application/json:
schema:
type: object
properties:
sort_order: { type: integer }
duration_sec: { type: integer }
zone_id: { type: [string, "null"] }
responses:
'200': { description: Updated item. }
delete:
tags: [playlists]
summary: Delete a playlist item
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: itemId, in: path, required: true, schema: { type: integer } }
responses:
'200': { description: '{ success }.' }
/playlists/{id}/items/{itemId}/schedules:
get:
tags: [playlists]
summary: Get a playlist item's schedule blocks
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: itemId, in: path, required: true, schema: { type: integer } }
responses:
'200': { description: Array of schedule blocks. }
put:
tags: [playlists]
summary: Replace a playlist item's schedule blocks
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: itemId, in: path, required: true, schema: { type: integer } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [blocks]
properties:
blocks:
type: array
items:
type: object
properties:
days:
type: array
items: { type: integer }
start: { type: string }
end: { type: string }
start_date: { type: [string, "null"] }
end_date: { type: [string, "null"] }
responses:
'200': { description: Stored schedule blocks. }
/playlists/{id}/assign:
post:
tags: [playlists]
summary: Assign the playlist to a device
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [device_id]
properties:
device_id: { type: string }
responses:
'200': { description: '{ success }.' }
# -------------------------------------------------------------- assignments
/assignments/device/{deviceId}:
get:
tags: [assignments]
summary: List a device's playlist items
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: deviceId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Array of items. }
post:
tags: [assignments]
summary: Add a content/widget item to a device's playlist
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: deviceId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
content_id: { type: string }
widget_id: { type: string }
zone_id: { type: string }
duration_sec:
type: integer
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
sort_order: { type: integer }
responses:
'201': { description: Created item. }
/assignments/device/{deviceId}/reorder:
post:
tags: [assignments]
summary: Reorder a device's playlist items
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: deviceId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [order]
properties:
order:
type: array
items: { type: integer }
responses:
'200': { description: Reordered items. }
/assignments/device/{deviceId}/copy-to/{targetDeviceId}:
post:
tags: [assignments]
summary: Copy a device's playlist to another device
description: 'Requires scope: write. Both devices must share a workspace.'
x-required-scope: write
parameters:
- { name: deviceId, in: path, required: true, schema: { type: string } }
- { name: targetDeviceId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
replace: { type: boolean }
responses:
'200': { description: '{ success, copied }.' }
/assignments/{id}:
put:
tags: [assignments]
summary: Update a playlist item
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: integer } }
requestBody:
content:
application/json:
schema:
type: object
properties:
sort_order: { type: integer }
duration_sec: { type: integer }
zone_id: { type: [string, "null"] }
muted:
type: boolean
description: >
Mute this item's audio. Applied to playing devices in real time
(device:mute-changed) and persisted into the published snapshot, so it
also takes effect on the next playlist load. (#129)
responses:
'200': { description: Updated item. }
delete:
tags: [assignments]
summary: Delete a playlist item
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: integer } }
responses:
'200': { description: '{ success, content_id }.' }
# ------------------------------------------------------------------ devices
/devices:
get:
tags: [devices]
summary: List devices in the current workspace
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: Array of devices.
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/Device' }
/devices/{id}:
get:
tags: [devices]
summary: Get a device with telemetry and assignments
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Device detail.
content:
application/json:
schema: { $ref: '#/components/schemas/Device' }
'404': { description: Not found. }
put:
tags: [devices]
summary: Update a device
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
notes: { type: string }
timezone: { type: string }
orientation: { type: string }
default_content_id: { type: string }
layout_id: { type: [string, "null"] }
responses:
'200':
description: Updated device.
content:
application/json:
schema: { $ref: '#/components/schemas/Device' }
delete:
tags: [devices]
summary: Delete a device
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success }.' }
# ------------------------------------------------------------------ layouts
/layouts:
get:
tags: [layouts]
summary: List layouts (workspace + templates)
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- name: templates
in: query
schema: { type: string }
description: '"true" to list only templates.'
responses:
'200':
description: Array of layouts with zones.
post:
tags: [layouts]
summary: Create a layout
description: 'Requires scope: write.'
x-required-scope: write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
width: { type: integer }
height: { type: integer }
zones:
type: array
items: { $ref: '#/components/schemas/Zone' }
responses:
'201':
description: Created layout.
content:
application/json:
schema: { $ref: '#/components/schemas/Layout' }
/layouts/{id}:
get:
tags: [layouts]
summary: Get a layout with zones
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Layout with zones.
content:
application/json:
schema: { $ref: '#/components/schemas/Layout' }
put:
tags: [layouts]
summary: Update a layout (and optionally replace zones)
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
width: { type: integer }
height: { type: integer }
zones:
type: array
items: { $ref: '#/components/schemas/Zone' }
responses:
'200': { description: Updated layout. }
delete:
tags: [layouts]
summary: Delete a layout
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success }.' }
/layouts/{id}/zones:
post:
tags: [layouts]
summary: Add a zone to a layout
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema: { $ref: '#/components/schemas/Zone' }
responses:
'201':
description: Created zone.
content:
application/json:
schema: { $ref: '#/components/schemas/Zone' }
/layouts/{id}/zones/{zoneId}:
put:
tags: [layouts]
summary: Update a zone
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: zoneId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema: { $ref: '#/components/schemas/Zone' }
responses:
'200': { description: Updated zone. }
delete:
tags: [layouts]
summary: Delete a zone
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: zoneId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success }.' }
/layouts/{id}/duplicate:
post:
tags: [layouts]
summary: Duplicate a layout into the current workspace
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
responses:
'201': { description: Duplicated layout. }
/layouts/device/{deviceId}:
put:
tags: [layouts]
summary: Assign a layout to a device
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: deviceId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
layout_id: { type: [string, "null"] }
responses:
'200': { description: '{ success }.' }
# ------------------------------------------------------------------- groups
/groups:
get:
tags: [groups]
summary: List device groups in the current workspace
description: 'Requires scope: read.'
x-required-scope: read
responses:
'200':
description: Array of groups.
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/Group' }
post:
tags: [groups]
summary: Create a device group
description: 'Requires scope: write.'
x-required-scope: write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
color: { type: string }
responses:
'201':
description: Created group.
content:
application/json:
schema: { $ref: '#/components/schemas/Group' }
/groups/{id}:
put:
tags: [groups]
summary: Update a group
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
color: { type: string }
responses:
'200': { description: Updated group. }
delete:
tags: [groups]
summary: Delete a group (converts group schedules to per-device)
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success, schedules_converted, devices }.' }
/groups/{id}/devices:
get:
tags: [groups]
summary: List devices in a group
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Array of devices. }
post:
tags: [groups]
summary: Add a device to a group
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [device_id]
properties:
device_id: { type: string }
responses:
'201': { description: '{ success, playlist_id }.' }
/groups/{id}/devices/{deviceId}:
delete:
tags: [groups]
summary: Remove a device from a group
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: deviceId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success }.' }
/groups/{id}/assign-content:
post:
tags: [groups]
summary: Add content to every device in a group
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [content_id]
properties:
content_id: { type: string }
duration_sec:
type: integer
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
responses:
'200': { description: '{ success, devices_updated }.' }
/groups/{id}/assign-playlist:
post:
tags: [groups]
summary: Assign a playlist to every device in a group
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [playlist_id]
properties:
playlist_id: { type: string }
responses:
'200': { description: '{ success, devices_updated }.' }
/groups/{id}/command:
post:
tags: [groups]
summary: Send an operational command to every device in a group
description: |
Sends reboot / shutdown / screen on/off / launch / update to all devices
in the group. Requires scope: full (the highest scope; tokens with only
read or write are rejected).
x-required-scope: full
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [type]
properties:
type:
type: string
enum: [screen_on, screen_off, launch, update, reboot, shutdown]
payload: { type: object }
responses:
'200': { description: '{ success, sent, offline, total, results }.' }
# ---------------------------------------------------------------------- pip
/pip:
post:
tags: [pip]
summary: Show a picture-in-picture overlay on a device or group
description: |
Pushes an image or web overlay onto a device (or every device in a group) on top
of the running playlist, in real time (device:pip-show). The player fetches `uri`
itself (same trust model as remote_url content; the server does not proxy it). A
`web` overlay renders an arbitrary page in an iframe, so this requires scope: full. (#109)
x-required-scope: full
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [device_id, type, uri]
properties:
device_id: { type: string, description: A device id OR a group id (expanded to its members). }
type: { type: string, enum: [image, web] }
uri: { type: string, description: Absolute http(s) URL the player fetches directly. }
position: { type: string, enum: [top-left, top-right, bottom-left, bottom-right, center], default: top-right }
width: { type: integer, description: 'px (40-3840)', default: 480 }
height: { type: integer, description: 'px (40-3840)', default: 360 }
duration: { type: integer, description: 'seconds; 0 = until cleared (0-86400)', default: 0 }
title: { type: string }
title_color: { type: string, description: '#RRGGBB' }
background_color: { type: string, description: '#RRGGBB (transparency via opacity)' }
opacity: { type: number, description: '0-1', default: 1 }
border_radius: { type: integer, description: 'px (0-512)', default: 0 }
close_button: { type: boolean }
responses:
'200': { description: '{ success, pip_id, target, sent, offline, total, results }.' }
'400': { description: Validation error (type / uri scheme / position / numeric bounds / color). }
'404': { description: Device or group not found in this workspace. }
delete:
tags: [pip]
summary: Clear a picture-in-picture overlay
description: 'Clears the overlay on a device or group (device:pip-clear). Requires scope: full. (#109)'
x-required-scope: full
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [device_id]
properties:
device_id: { type: string, description: A device id OR a group id. }
pip_id: { type: string, description: Omit to clear whatever overlay is showing. }
responses:
'200': { description: '{ success, target, sent, offline, total, results }.' }
'404': { description: Device or group not found in this workspace. }
/pip/clear:
post:
tags: [pip]
summary: Clear a picture-in-picture overlay (alias for DELETE /pip)
description: 'Same as DELETE /pip; provided for clients that prefer POST. Requires scope: full. (#109)'
x-required-scope: full
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [device_id]
properties:
device_id: { type: string, description: A device id OR a group id. }
pip_id: { type: string, description: Omit to clear whatever overlay is showing. }
responses:
'200': { description: '{ success, target, sent, offline, total, results }.' }
'404': { description: Device or group not found in this workspace. }
# ---------------------------------------------------------------- schedules
/schedules:
get:
tags: [schedules]
summary: List schedules (filterable)
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: device_id, in: query, schema: { type: string } }
- { name: group_id, in: query, schema: { type: string } }
- { name: start, in: query, schema: { type: string } }
- { name: end, in: query, schema: { type: string } }
responses:
'200':
description: Array of schedules.
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/Schedule' }
post:
tags: [schedules]
summary: Create a schedule (targets one device OR one group)
description: 'Requires scope: write.'
x-required-scope: write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [start_time, end_time]
properties:
device_id: { type: string }
group_id: { type: string }
zone_id: { type: string }
content_id: { type: string }
widget_id: { type: string }
layout_id: { type: string }
playlist_id: { type: string }
title: { type: string }
start_time: { type: string }
end_time: { type: string }
timezone: { type: string }
recurrence: { type: string }
recurrence_end: { type: string }
priority: { type: integer }
color: { type: string }
responses:
'201':
description: Created schedule.
content:
application/json:
schema: { $ref: '#/components/schemas/Schedule' }
/schedules/device/{deviceId}:
get:
tags: [schedules]
summary: Get schedules for a device (device + group level)
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: deviceId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Array of schedules. }
/schedules/week:
get:
tags: [schedules]
summary: Expanded week view for a device (resolves recurrences)
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: device_id, in: query, required: true, schema: { type: string } }
- { name: date, in: query, schema: { type: string } }
responses:
'200': { description: Array of schedule instances. }
/schedules/{id}:
put:
tags: [schedules]
summary: Update a schedule
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema: { $ref: '#/components/schemas/Schedule' }
responses:
'200': { description: Updated schedule. }
delete:
tags: [schedules]
summary: Delete a schedule
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success }.' }
# -------------------------------------------------------------------- walls
/walls:
get:
tags: [walls]
summary: List video walls (with attached devices)
description: 'Requires scope: read.'
x-required-scope: read
responses:
'200': { description: Array of walls. }
post:
tags: [walls]
summary: Create a video wall
description: 'Requires scope: write.'
x-required-scope: write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
grid_cols: { type: integer }
grid_rows: { type: integer }
bezel_h_mm: { type: number }
bezel_v_mm: { type: number }
playlist_id: { type: string }
responses:
'201': { description: Created wall. }
/walls/{id}:
get:
tags: [walls]
summary: Get a video wall with devices
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Wall with devices. }
put:
tags: [walls]
summary: Update a video wall
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
grid_cols: { type: integer }
grid_rows: { type: integer }
bezel_h_mm: { type: number }
bezel_v_mm: { type: number }
sync_mode: { type: string }
leader_device_id: { type: string }
content_id: { type: string }
playlist_id: { type: string }
responses:
'200': { description: Updated wall. }
delete:
tags: [walls]
summary: Delete a video wall
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success }.' }
/walls/{id}/devices:
put:
tags: [walls]
summary: Set the wall's device grid positions (replaces member set)
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [devices]
properties:
devices:
type: array
items:
type: object
properties:
device_id: { type: string }
grid_col: { type: integer }
grid_row: { type: integer }
rotation: { type: integer }
canvas_x: { type: number }
canvas_y: { type: number }
canvas_width: { type: number }
canvas_height: { type: number }
responses:
'200': { description: Updated wall with devices. }
/walls/{id}/content:
put:
tags: [walls]
summary: Set wall content (legacy single-video path)
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
content_id: { type: string }
responses:
'200': { description: '{ success }.' }
/walls/{id}/device-config/{deviceId}:
get:
tags: [walls]
summary: Get wall config for a specific member device
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: deviceId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Per-device wall config. }
# ------------------------------------------------------------------ reports
/reports/plays:
get:
tags: [reports]
summary: Query play logs
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: device_id, in: query, schema: { type: string } }
- { name: content_id, in: query, schema: { type: string } }
- { name: start, in: query, schema: { type: string } }
- { name: end, in: query, schema: { type: string } }
- { name: limit, in: query, schema: { type: integer } }
responses:
'200': { description: Array of play-log rows. }
/reports/summary:
get:
tags: [reports]
summary: Aggregated play summary report
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: device_id, in: query, schema: { type: string } }
- { name: start, in: query, schema: { type: string } }
- { name: end, in: query, schema: { type: string } }
- { name: group_by, in: query, schema: { type: string } }
responses:
'200':
description: 'Summary object: overall, by_content, by_device, by_hour, by_day.'
/reports/export:
get:
tags: [reports]
summary: Export proof-of-play as CSV
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: device_id, in: query, schema: { type: string } }
- { name: start, in: query, schema: { type: string } }
- { name: end, in: query, schema: { type: string } }
responses:
'200':
description: CSV file.
content:
text/csv:
schema: { type: string }
/reports/uptime:
get:
tags: [reports]
summary: Device uptime report
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: device_id, in: query, schema: { type: string } }
- { name: start, in: query, schema: { type: string } }
- { name: end, in: query, schema: { type: string } }
responses:
'200': { description: Array of per-device uptime rows. }
# ------------------------------------------------------------------ widgets
/widgets:
get:
tags: [widgets]
summary: List widgets in the current workspace
description: 'Requires scope: read.'
x-required-scope: read
responses:
'200':
description: Array of widgets.
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/Widget' }
post:
tags: [widgets]
summary: Create a widget
description: 'Requires scope: write.'
x-required-scope: write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [widget_type, name]
properties:
widget_type: { type: string }
name: { type: string }
config: { type: object }
responses:
'201':
description: Created widget.
content:
application/json:
schema: { $ref: '#/components/schemas/Widget' }
/widgets/preview:
post:
tags: [widgets]
summary: Render an unsaved widget config to HTML (non-persisting)
description: 'Requires scope: write (any POST needs write under the scope ladder); renders to HTML without persisting anything.'
x-required-scope: write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [widget_type]
properties:
widget_type: { type: string }
config: { type: object }
responses:
'200':
description: Rendered widget HTML.
content:
text/html:
schema: { type: string }
/widgets/{id}:
get:
tags: [widgets]
summary: Get a widget
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Widget.
content:
application/json:
schema: { $ref: '#/components/schemas/Widget' }
put:
tags: [widgets]
summary: Update a widget
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
config: { type: object }
responses:
'200': { description: Updated widget. }
delete:
tags: [widgets]
summary: Delete a widget
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success }.' }
/widgets/{id}/render:
get:
tags: [widgets]
summary: Render a widget as an HTML page (public)
description: |
Unauthenticated device-render endpoint. Players fetch this HTML directly,
so no token or scope is required.
security: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Rendered widget HTML.
content:
text/html:
schema: { type: string }
# -------------------------------------------------------------------- kiosk
/kiosk:
get:
tags: [kiosk]
summary: List kiosk pages in the current workspace
description: 'Requires scope: read.'
x-required-scope: read
responses:
'200': { description: Array of kiosk pages. }
post:
tags: [kiosk]
summary: Create a kiosk page
description: 'Requires scope: write.'
x-required-scope: write
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
config: { type: object }
responses:
'201': { description: Created kiosk page. }
/kiosk/{id}:
get:
tags: [kiosk]
summary: Get a kiosk page
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Kiosk page. }
put:
tags: [kiosk]
summary: Update a kiosk page
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
config: { type: object }
responses:
'200': { description: Updated kiosk page. }
delete:
tags: [kiosk]
summary: Delete a kiosk page
description: 'Requires scope: write.'
x-required-scope: write
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200': { description: '{ success }.' }
/kiosk/{id}/render:
get:
tags: [kiosk]
summary: Render a kiosk page as an HTML page (public)
description: |
Unauthenticated device-render endpoint. Devices fetch this HTML directly,
so no token or scope is required.
security: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Rendered kiosk HTML.
content:
text/html:
schema: { type: string }
# ----------------------------------------------------------------- activity
/activity:
get:
tags: [activity]
summary: Get the activity log
description: 'Requires scope: read.'
x-required-scope: read
parameters:
- { name: device_id, in: query, schema: { type: string } }
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200': { description: Array of activity entries. }