@curator/api
HTTP API over @curator/frame, built on
ServerKit Fastify.
Layout
src/
index.ts entry point: load config, start the server
migrate.ts compiled entry point that applies data/migrations with dbmate (the Docker image)
server/ config loading and validation, the plugin stack, buildServer/startServer
logging/ the rotating log file, the logger that tees to it, and request/rotation traces
modules/ ServerKit modules (DI registrations and lifecycle hooks), in setup order
data/ Postgres through Kysely; kysely.ts is generated by `pnpm build:datatypes`
tvs/ the stored TVs: repository, service (connect, rename, remove), token store
frame/ one client per TV (FrameClientRegistry), and FrameService, which routes call
discovery/ SSDP search for Frame TVs on the network
routes/ Fastify route plugins, schemas declared with zod
data/migrations/ dbmate migrations
scripts/with.database.url.ts runs dbmate or kysely-codegen against the configured database
tests/
test.db.ts per-test-file databases, cloned from a template migrated once per run
test.server.ts builds the real server for app.inject(), with FakeFrameClient in place of the TV
fake.frame.client.ts an in-memory FrameClient
Database
Postgres 17 from the root docker-compose.yml: curator-db on 14132 for development and
curator-db-test on 14133 (tmpfs) for the tests. pnpm db:up starts both.
pnpm migrate:up # apply data/migrations to the configured database
pnpm migrate:create name # a new migration
pnpm build:datatypes # regenerate src/modules/data/kysely.ts from the migrated schema
The test run migrates a template database once (tests/setup.global.ts) and every test file
clones its own, so tests never share rows. Edit a migration in place freely until it ships: the
template is rebuilt from scratch each run.
Running
pnpm dev # from apps/api: rebuilds @curator/frame, then watch mode on :14100
pnpm build && pnpm start
Config is appconfig.${NODE_ENV}.yaml (default development), overlaid by a gitignored
appconfig.local.yaml. Any string value may be ${env:NAME}. The whole tree is validated at boot
(src/server/api.config.ts), so a bad value fails with its key named.
TVs live in the database, not in config. Add one from the web app's TVs page, or:
curl -X POST localhost:14100/tvs -H 'content-type: application/json' -d '{"host":"192.168.1.50"}'
then accept the pairing prompt on the TV. The token it issues is kept in the TV's row. Give each TV
a DHCP reservation, since a TV is stored by address. frame.clientName (default Curator) is the
name the prompt shows; frame.debug: true logs every frame on the wire, for every TV.
To run without a TV, start the fake one (pnpm fake:tv in packages/frame) and add it with
{"host":"127.0.0.1","port":14180}.
There is no authentication: the API is meant for the home network the TV sits on.
The API can also serve the web app, so one process answers on one origin (this is how the Docker
image runs). Set web.dir to the web build (apps/web/dist) and server.routePrefix to /api,
where the web app looks for the API. Any GET outside /api that names no file answers
index.html, for the web app's client-side routes.
Logging
pnpm dev and pnpm start log to stdout and to .data/logs/api.log (the log config section sets
the directory and the rotation limits). The file rotates by size, so it never holds more than
(maxFiles + 1) × maxBytes.
- Shape:
logger.warn('area: what happened', { tvId, contentId, error: errorText(error) }). Write the message as a lower-casearea:prefix and a plain sentence, then put the details in a flat object. Don't build the details into the message with a template string.errorText(src/logging/error.text.ts) turns a caughtunknowninto a string. - Redaction: a value whose key looks like a credential (
token,password,secret, ...) is written as***, and so is anyBearer ...inside a value. - Traces: in the file, each line carries
trace=. For a request that is itsx-request-id; for a rotation advance it is an id of its own. Filtering on it pulls one request's lines out of everything else. Stdout leaves it off. - Frame client: its lines are prefixed
frame:and tagged with the TV. Its wire-level debug lines appear only withframe.debug: true.
Routes
| Method | Path | Body / answers |
|---|---|---|
| GET | /health | { status: 'ok', uptimeMs }. Never touches a TV. |
| GET | /logs | { items: [{ ts, level, text }] }, the API's log, newest first. ?limit= (default 200, max 1000), ?level= the least severe to include |
| GET | /tvs | { items: [Tv] }, oldest first |
| GET | /tvs/:tvId | Tv: { id, name, host, port, model, paired, createdAt, updatedAt } |
| PATCH | /tvs/:tvId | { name } renames it. Answers the Tv |
| DELETE | /tvs/:tvId | Forgets the TV and closes its connection. 204 |
POST /tvs checks the address is a Frame, then pairs with it: the TV shows a prompt that someone
must accept with the remote, so the request can take up to 30 seconds. The TV is stored only once
pairing succeeds. It answers 409 (with details.tvId) for an address already added, 422 for a
Samsung TV that isn't a Frame, 503 when nothing answers, and 502 when the prompt is declined or
times out. port defaults to 8002; name to the name the TV reports.
GET /tvs/discover sends an SSDP search (UDP multicast to 239.255.255.250:1900) for Samsung TVs,
listens for discovery.timeoutMs (3 seconds by default), then asks each one that answered whether
it is a Frame. Other Samsung TVs, and devices that don't answer on 8002, are left out. tvId is
set on a Frame that is already added. Multicast doesn't cross subnets and is often blocked between
Docker and the LAN, so add a TV by address when it isn't found.
A TV's pairing token is stored in its row and never returned. paired: false means the TV must be
paired again. An unknown tvId is a 404, a malformed one a 400.
A TV's own endpoints
Under /tvs/:tvId, each TV through its own client:
| Method | Path | Body / answers |
|---|---|---|
| GET | /device | Device info: { model, name, frameTvSupport, powerOn, raw } |
| GET | /api-version | { version } of the TV's art API |
| GET | /art?category= | { items: [{ contentId, categoryId, categoryIds, raw }] } |
| GET | /art/:contentId/thumbnail | The piece's thumbnail as image/jpeg (or png). 404 if the TV has none |
| GET | /art/current | { contentId, categoryId, raw } for the artwork on screen |
| GET | /art-mode | { on } |
| POST | /art?matte=&portraitMatte= | Raw image/jpeg or image/png body, up to 20MB. 201 { contentId } |
| PUT | /art/current | { contentId, show?, category? } selects it. 204 |
| DELETE | /art/:contentId | Deletes it from the TV. 204 |
| PUT | /art/:contentId/matte | { matte, portraitMatte? }, e.g. shadowbox_polar. 204 |
| PUT | /art-mode | { on }. 204 |
Thumbnails are fetched in batches: requests for one TV that arrive within 25ms share one TV request
(up to 24 ids), and results are kept in memory (frame.thumbnailCacheMegabytes, 64 by default) and
marked immutable for the browser, since a content id always names the same image.
Categories: MY-C0002 uploads, MY-C0004 favourites. The reference calls MY-C0008 the Art
Store, but a 2025 Frame lists uploads there too. A TV may list a piece under several categories,
even twice under one; GET …/art answers each piece once, with all of them in categoryIds.
The TV often leaves writes unanswered, so a 204 means the request was sent, not that the TV acted
on it. Read the state back (GET …/art/current, GET …/art-mode) when it matters. A write the
TV explicitly rejects, such as a matte that doesn't fit the image, still answers 422.
Uploads are the exception: they wait for the TV to announce the new image, so a 201 carries the content id it assigned.
curl -X POST "localhost:14100/tvs/$TV_ID/art?matte=shadowbox_polar" -H 'content-type: image/jpeg' --data-binary @photo.jpg
When the TV fails
| Status | Means |
|---|---|
| 503 | The TV is unreachable: asleep, off the network, or it closed the socket |
| 504 | The TV took the request but never answered |
| 502 | Pairing was refused, or the TV sent something the client couldn't read |
| 422 | The TV rejected the request (an unknown content id, an unsupported matte) |
details.reason carries the client's message.