Skip to main content

@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-case area: 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 caught unknown into a string.
  • Redaction: a value whose key looks like a credential (token, password, secret, ...) is written as ***, and so is any Bearer ... inside a value.
  • Traces: in the file, each line carries trace=. For a request that is its x-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 with frame.debug: true.

Routes​

MethodPathBody / 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/:tvIdTv: { id, name, host, port, model, paired, createdAt, updatedAt }
PATCH/tvs/:tvId{ name } renames it. Answers the Tv
DELETE/tvs/:tvIdForgets 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:

MethodPathBody / answers
GET/deviceDevice 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/thumbnailThe 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/:contentIdDeletes 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​

StatusMeans
503The TV is unreachable: asleep, off the network, or it closed the socket
504The TV took the request but never answered
502Pairing was refused, or the TV sent something the client couldn't read
422The TV rejected the request (an unknown content id, an unsupported matte)

details.reason carries the client's message.