@deadair/plugin-sdk 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1062 -0
- package/dist/article.parse.d.ts +82 -0
- package/dist/article.parse.d.ts.map +1 -0
- package/dist/boundary.json.safe.d.ts +191 -0
- package/dist/boundary.json.safe.d.ts.map +1 -0
- package/dist/capabilities/analysis.d.ts +330 -0
- package/dist/capabilities/analysis.d.ts.map +1 -0
- package/dist/capabilities/charts.d.ts +134 -0
- package/dist/capabilities/charts.d.ts.map +1 -0
- package/dist/capabilities/enrichment.d.ts +255 -0
- package/dist/capabilities/enrichment.d.ts.map +1 -0
- package/dist/capabilities/llm.d.ts +318 -0
- package/dist/capabilities/llm.d.ts.map +1 -0
- package/dist/capabilities/mixer.d.ts +183 -0
- package/dist/capabilities/mixer.d.ts.map +1 -0
- package/dist/capabilities/music.provider.d.ts +245 -0
- package/dist/capabilities/music.provider.d.ts.map +1 -0
- package/dist/capabilities/news.d.ts +171 -0
- package/dist/capabilities/news.d.ts.map +1 -0
- package/dist/capabilities/scrobble.d.ts +133 -0
- package/dist/capabilities/scrobble.d.ts.map +1 -0
- package/dist/capabilities/search.d.ts +122 -0
- package/dist/capabilities/search.d.ts.map +1 -0
- package/dist/capabilities/similarity.d.ts +101 -0
- package/dist/capabilities/similarity.d.ts.map +1 -0
- package/dist/capabilities/speech.d.ts +211 -0
- package/dist/capabilities/speech.d.ts.map +1 -0
- package/dist/capabilities/weather.d.ts +192 -0
- package/dist/capabilities/weather.d.ts.map +1 -0
- package/dist/chunk-7QVYU63E.js +7 -0
- package/dist/chunk-7QVYU63E.js.map +1 -0
- package/dist/define.plugin.d.ts +56 -0
- package/dist/define.plugin.d.ts.map +1 -0
- package/dist/feed.parse.d.ts +97 -0
- package/dist/feed.parse.d.ts.map +1 -0
- package/dist/html.text.d.ts +71 -0
- package/dist/html.text.d.ts.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1153 -0
- package/dist/index.js.map +1 -0
- package/dist/match.text.d.ts +28 -0
- package/dist/match.text.d.ts.map +1 -0
- package/dist/plugin.api.version.d.ts +9 -0
- package/dist/plugin.api.version.d.ts.map +1 -0
- package/dist/plugin.base.d.ts +80 -0
- package/dist/plugin.base.d.ts.map +1 -0
- package/dist/plugin.config.fields.d.ts +524 -0
- package/dist/plugin.config.fields.d.ts.map +1 -0
- package/dist/plugin.config.read.d.ts +52 -0
- package/dist/plugin.config.read.d.ts.map +1 -0
- package/dist/plugin.error.d.ts +177 -0
- package/dist/plugin.error.d.ts.map +1 -0
- package/dist/plugin.host.d.ts +220 -0
- package/dist/plugin.host.d.ts.map +1 -0
- package/dist/plugin.host.response.d.ts +42 -0
- package/dist/plugin.host.response.d.ts.map +1 -0
- package/dist/plugin.http.d.ts +80 -0
- package/dist/plugin.http.d.ts.map +1 -0
- package/dist/plugin.lifecycle.d.ts +68 -0
- package/dist/plugin.lifecycle.d.ts.map +1 -0
- package/dist/plugin.manifest.d.ts +282 -0
- package/dist/plugin.manifest.d.ts.map +1 -0
- package/dist/plugin.permissions.d.ts +186 -0
- package/dist/plugin.permissions.d.ts.map +1 -0
- package/dist/testing/fake.plugin.host.d.ts +85 -0
- package/dist/testing/fake.plugin.host.d.ts.map +1 -0
- package/dist/testing/index.d.ts +10 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +141 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +67 -0
package/README.md
ADDED
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
# @deadair/plugin-sdk
|
|
2
|
+
|
|
3
|
+
Everything you need to write a deadair plugin, and nothing else. This package
|
|
4
|
+
is the only thing a plugin imports from deadair: no database, no DI container,
|
|
5
|
+
no HTTP framework. `zod` is a peer dependency, so you and the host share one
|
|
6
|
+
copy; the one dependency of its own is the XML parser behind `parseFeed`.
|
|
7
|
+
|
|
8
|
+
A plugin extends deadair by declaring capabilities:
|
|
9
|
+
|
|
10
|
+
| capability | what it lets you do |
|
|
11
|
+
| ------------ | -------------------------------------------------------------- |
|
|
12
|
+
| `catalog` | supply music: list playlists, list their tracks |
|
|
13
|
+
| `stream` | get the station the audio to play (`resolveStreamUrl`) |
|
|
14
|
+
| `steer` | own your audio output and let deadair only tell you what to do |
|
|
15
|
+
| `enrichment` | supply facts about a track: year, genre, label, trivia, prose |
|
|
16
|
+
| `speech` | say something out loud: text in, audio out |
|
|
17
|
+
| `llm` | produce words: a conversation in, text out |
|
|
18
|
+
| `analysis` | measure a track's audio: bytes in, cue points and loudness out |
|
|
19
|
+
| `mixer` | make one piece of audio out of several: parts in, audio out |
|
|
20
|
+
| `charts` | say what is popular: a chart id in, ranked names out |
|
|
21
|
+
| `similarity` | say who else sounds like this: an artist in, artists out |
|
|
22
|
+
| `news` | say what happened outside the station: a feed in, entries out |
|
|
23
|
+
| `search` | ask the open web a question: words in, pages out |
|
|
24
|
+
| `weather` | say what it is like outside: a place in, measurements out |
|
|
25
|
+
| `scrobble` | report what the station played to somebody else's service |
|
|
26
|
+
| `oauth` | hold operator tokens, obtained through the host's redirect |
|
|
27
|
+
|
|
28
|
+
There is no second axis. `capabilities` is the whole declaration, and the host
|
|
29
|
+
checks it on every call together with whether you actually implemented the
|
|
30
|
+
methods, because a plugin that claims a capability and forgets the method is a
|
|
31
|
+
`TypeError` in the middle of a request rather than an honest "not supported".
|
|
32
|
+
|
|
33
|
+
## The shape of a plugin
|
|
34
|
+
|
|
35
|
+
A plugin package default-exports the result of `definePlugin(manifest, factory)`
|
|
36
|
+
and points at that entry file from its own `package.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"name": "deadair-plugin-discogs",
|
|
41
|
+
"version": "1.0.0",
|
|
42
|
+
"type": "module",
|
|
43
|
+
"main": "./dist/index.js",
|
|
44
|
+
"deadair": {
|
|
45
|
+
"plugin": "./dist/index.js"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@deadair/plugin-sdk": ">=0.1.0",
|
|
49
|
+
"zod": "^4.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The `deadair.plugin` field is how the host finds your entry point. Without it,
|
|
55
|
+
your package is just a package.
|
|
56
|
+
|
|
57
|
+
The SDK and zod are PEERS, and that is the one part of this file that is not a
|
|
58
|
+
matter of taste: the station hands your plugin its own copy of each, linked into
|
|
59
|
+
the plugins directory beside it, so a class you extend is the class the host
|
|
60
|
+
checks against. Ship neither. The range that is enforced is your manifest's
|
|
61
|
+
`apiVersion` (see [Versioning](#versioning)); the package range is advice to
|
|
62
|
+
whoever installs your devDependencies, which is why it is a floor rather than a
|
|
63
|
+
caret. How to build, package and install one from outside this repository is on
|
|
64
|
+
the website, at https://deadair.radio/docs/plugin-development.
|
|
65
|
+
|
|
66
|
+
## The rules
|
|
67
|
+
|
|
68
|
+
1. **No ambient I/O.** Get at the world through the `PluginHost`. `host.fetch()`
|
|
69
|
+
is your egress, and it will refuse any hostname you did not declare in
|
|
70
|
+
`permissions.network`.
|
|
71
|
+
|
|
72
|
+
This is a rule, not a cage, and it is never going to be one. The host
|
|
73
|
+
imports you into its own process, permanently, so global `fetch` and `fs`
|
|
74
|
+
are within reach. Use them and you opt out of the rate limiting, timeouts,
|
|
75
|
+
redirect checks and audit logging the host does on your behalf, and you make
|
|
76
|
+
your manifest a lie to the operator who installed you.
|
|
77
|
+
|
|
78
|
+
2. **Payloads that get stored or sent are JSON-safe.** No `Date`, no class
|
|
79
|
+
instances, no functions on anything in `capabilities/`. Durations are
|
|
80
|
+
integers in milliseconds; dates are ISO-8601 strings. Not because the
|
|
81
|
+
boundary is a wire (it is a function call) but because those values end up in
|
|
82
|
+
Postgres and in the console's JSON, and a `Date` comes back out of a `jsonb`
|
|
83
|
+
column as a string either way.
|
|
84
|
+
|
|
85
|
+
The host's own methods are under no such rule: `host.fetch` hands you a real
|
|
86
|
+
`Response`, `host.signal` a real `AbortSignal`, and `speak()` returns a real
|
|
87
|
+
`ReadableStream`.
|
|
88
|
+
|
|
89
|
+
3. **Ask for what you need and no more.** `permissions` is shown to the
|
|
90
|
+
operator before they enable you.
|
|
91
|
+
4. **`undefined`, never `null`,** for "not set".
|
|
92
|
+
5. **Do no work in the factory.** Build the object, put setup in `onLoad()`, and
|
|
93
|
+
register the undo for anything you start.
|
|
94
|
+
6. **Secrets are write-only.** A `secret` config field is encrypted at rest and
|
|
95
|
+
never read back into the settings UI. Read it with `host.secrets.get()`.
|
|
96
|
+
7. **A URL you hand back to be stored must be stable.** `artworkUrl` is kept,
|
|
97
|
+
and the host's art cache is keyed by the URL string itself, so the same
|
|
98
|
+
image has to mint the same URL every time you are asked about it. If yours
|
|
99
|
+
carries credentials, fix the varying part — a salt, a nonce, a timestamp —
|
|
100
|
+
once in `init()` and reuse it for art. Vary it per call and every mention
|
|
101
|
+
becomes a new row and another download of identical bytes; nothing errors,
|
|
102
|
+
it just never caches. `resolveStreamUrl` is under no such rule, because
|
|
103
|
+
nothing stores what it returns.
|
|
104
|
+
|
|
105
|
+
## A complete minimal plugin
|
|
106
|
+
|
|
107
|
+
An enrichment plugin that looks a track up by ISRC and returns its release
|
|
108
|
+
year and genres.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import {
|
|
112
|
+
definePlugin,
|
|
113
|
+
type EnrichmentPluginInstance,
|
|
114
|
+
jsonBody,
|
|
115
|
+
Plugin,
|
|
116
|
+
type PluginManifest,
|
|
117
|
+
type TrackEnrichment,
|
|
118
|
+
type TrackRef,
|
|
119
|
+
} from '@deadair/plugin-sdk';
|
|
120
|
+
import { z } from 'zod';
|
|
121
|
+
|
|
122
|
+
const configSchema = z.object({
|
|
123
|
+
apiKey: z.string().min(1),
|
|
124
|
+
includeGenres: z.boolean().default(true),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const manifest: PluginManifest = {
|
|
128
|
+
id: 'example.recordbin',
|
|
129
|
+
name: 'Record Bin',
|
|
130
|
+
version: '1.0.0',
|
|
131
|
+
capabilities: ['enrichment'],
|
|
132
|
+
apiVersion: '^1.0.0',
|
|
133
|
+
description: 'Release years and genres from the Record Bin catalogue.',
|
|
134
|
+
homepage: 'https://example.com/recordbin',
|
|
135
|
+
permissions: {
|
|
136
|
+
network: ['api.recordbin.example.com'],
|
|
137
|
+
storage: false,
|
|
138
|
+
oauth: false,
|
|
139
|
+
},
|
|
140
|
+
configFields: [
|
|
141
|
+
{
|
|
142
|
+
key: 'apiKey',
|
|
143
|
+
label: 'API key',
|
|
144
|
+
type: 'secret',
|
|
145
|
+
required: true,
|
|
146
|
+
help: 'Create one under Account → Developers.',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
key: 'includeGenres',
|
|
150
|
+
label: 'Include genres',
|
|
151
|
+
type: 'boolean',
|
|
152
|
+
default: true,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
configSchema,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
class RecordBinPlugin extends Plugin implements EnrichmentPluginInstance {
|
|
159
|
+
priority = 500;
|
|
160
|
+
matchKeys: EnrichmentPluginInstance['matchKeys'] = ['isrc'];
|
|
161
|
+
|
|
162
|
+
private apiKey?: string;
|
|
163
|
+
private includeGenres = true;
|
|
164
|
+
|
|
165
|
+
protected async onLoad(): Promise<void> {
|
|
166
|
+
this.apiKey = await this.host.secrets.get('apiKey');
|
|
167
|
+
const config = await this.host.config.get();
|
|
168
|
+
this.includeGenres = config.includeGenres !== false;
|
|
169
|
+
this.host.logger.info('record bin ready');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async testConnection(): Promise<{ ok: boolean; message?: string }> {
|
|
173
|
+
const response = await this.request('/health');
|
|
174
|
+
return response.ok ? { ok: true, message: 'Connected.' } : { ok: false, message: `HTTP ${response.status}` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async enrichTrack(ref: TrackRef): Promise<Partial<TrackEnrichment>> {
|
|
178
|
+
if (!ref.isrc) return {};
|
|
179
|
+
|
|
180
|
+
const response = await this.request(`/recordings/${encodeURIComponent(ref.isrc)}`);
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
await response.body?.cancel().catch(() => {});
|
|
183
|
+
this.host.logger.warn('lookup failed', { isrc: ref.isrc, status: response.status });
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const body = await jsonBody<{ year?: number; genres?: string[]; label?: string }>(response);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
year: body.year,
|
|
191
|
+
label: body.label,
|
|
192
|
+
genres: this.includeGenres ? body.genres : undefined,
|
|
193
|
+
links: [{ label: 'Record Bin', url: `https://example.com/recordbin/${ref.isrc}` }],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async request(path: string): Promise<Response> {
|
|
198
|
+
return await this.host.fetch(`https://api.recordbin.example.com${path}`, {
|
|
199
|
+
headers: { authorization: `Bearer ${this.apiKey ?? ''}` },
|
|
200
|
+
timeoutMs: 5_000,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export default definePlugin(manifest, () => new RecordBinPlugin());
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## What `host.fetch` gives back
|
|
209
|
+
|
|
210
|
+
A real `Response`. Not a copy, not a POJO: `await response.json()` is how you
|
|
211
|
+
read JSON, `response.body` is how you stream audio, and you can hand it to any
|
|
212
|
+
library that takes one. That is what lets the Spotify SDK's `fetch` hook be
|
|
213
|
+
wired straight through instead of adapted in both directions.
|
|
214
|
+
|
|
215
|
+
Two things about it are the host's doing rather than the platform's, and both
|
|
216
|
+
are part of the contract:
|
|
217
|
+
|
|
218
|
+
- **`url` is the last hop of the redirect chain,** not necessarily what you
|
|
219
|
+
asked for, so relative links in the body resolve against it. `redirected`
|
|
220
|
+
tells you whether it moved. The host follows redirects by hand to re-check
|
|
221
|
+
your allowlist on every hop, so it sets both itself.
|
|
222
|
+
- **The body is bounded.** `timeoutMs` covers getting the response (connect,
|
|
223
|
+
headers, the whole redirect chain) and stops there, because a large body
|
|
224
|
+
legitimately outlives the call that asked for it. Reading it is bounded
|
|
225
|
+
separately by an idle deadline between chunks, a total byte cap and a lifetime
|
|
226
|
+
cap. Exceeding any of them fails the read rather than truncating it, so bytes
|
|
227
|
+
you get are always bytes the server sent.
|
|
228
|
+
|
|
229
|
+
**A body you are not going to read is one you should `cancel()`.** The host
|
|
230
|
+
force-cancels whatever you still hold when your plugin is disposed, and the
|
|
231
|
+
lifetime cap catches the rest, but neither is prompt. On an error path, let it
|
|
232
|
+
go yourself:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
await response.body?.cancel().catch(() => {});
|
|
237
|
+
throw new PluginError(`upstream said ${response.status}`).withCode('upstream');
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`jsonBody(response)` parses the body and throws with the status, the URL and the
|
|
242
|
+
start of the body when it will not parse, which beats
|
|
243
|
+
`Unexpected token < in JSON at position 0` when an API answers a 200 with an
|
|
244
|
+
HTML error page. `tryJsonBody(response)` returns `undefined` instead of
|
|
245
|
+
throwing. Both are `async`, and both are ordinary functions rather than methods,
|
|
246
|
+
so the response you hold stays the platform's own.
|
|
247
|
+
|
|
248
|
+
The host sets a `User-Agent` for you (`<your plugin id>/<version> (deadair)`)
|
|
249
|
+
when you do not set one yourself, because a number of APIs refuse the default
|
|
250
|
+
one Node sends. Set the header yourself when the upstream's policy asks for
|
|
251
|
+
more than that: MusicBrainz wants a contact address, which usually means a
|
|
252
|
+
`contact` config field the operator fills in. Yours always wins.
|
|
253
|
+
|
|
254
|
+
An upstream that answers is not a failure: a 404 or a 500 comes back as an
|
|
255
|
+
ordinary `Response` with `ok: false`, and what it means is yours to decide.
|
|
256
|
+
`host.fetch` only _rejects_ when there is no response to give you, and when it
|
|
257
|
+
does it rejects with a `PluginError` carrying the same
|
|
258
|
+
[`PluginErrorCode`](src/plugin.error.ts) vocabulary your own failures use, so
|
|
259
|
+
you can branch on it:
|
|
260
|
+
|
|
261
|
+
- `upstream` — the request never completed, or the server misbehaved (an
|
|
262
|
+
unreachable host, a redirect chain past the cap, a redirect somewhere your
|
|
263
|
+
manifest does not allow, a body over the size cap).
|
|
264
|
+
- `timeout` — the host abandoned the call at its deadline, or the body went
|
|
265
|
+
quiet for longer than the idle deadline allows.
|
|
266
|
+
- `rate_limited` — you are over the host's fetch quota by more time than the
|
|
267
|
+
call had left. `retryAfterMs` says how long the wait would have been.
|
|
268
|
+
- `forbidden` — the hostname is not in your `permissions.network`.
|
|
269
|
+
- `config` — the URL did not parse, or was not http(s). Usually an operator
|
|
270
|
+
setting you built it from.
|
|
271
|
+
|
|
272
|
+
The body's own failures arrive the same way, on the read rather than on the
|
|
273
|
+
fetch, because that is when they happen.
|
|
274
|
+
|
|
275
|
+
Let these propagate unless you can do something better with them. The host
|
|
276
|
+
maps each one to a status and error code for the operator console, and
|
|
277
|
+
swallowing them turns a precise answer into a silent empty result.
|
|
278
|
+
|
|
279
|
+
## When the body should not arrive whole
|
|
280
|
+
|
|
281
|
+
Nothing special. `response.body` is a `ReadableStream<Uint8Array>` and you read
|
|
282
|
+
it, or pass it on, or pipe it through something:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
const response = await host.fetch(`${this.baseUrl}/audio/speech`, { method: 'POST', body });
|
|
286
|
+
if (!response.ok || response.body === null) {
|
|
287
|
+
await response.body?.cancel().catch(() => {});
|
|
288
|
+
throw new PluginError(`engine said ${response.status}`).withCode('upstream');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { mime: 'audio/mpeg', audio: response.body };
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
There was once a second egress here, `host.streams`, with handles, sequence
|
|
295
|
+
numbers, base64 chunks and an idempotent `close()`, because a live object could
|
|
296
|
+
not cross the boundary. All of it is gone: a `ReadableStream` is already a
|
|
297
|
+
pull-based stream with backpressure and a cancel, and the boundary is a function
|
|
298
|
+
call. See `CLAUDE.md` § "Trust and egress".
|
|
299
|
+
|
|
300
|
+
What survives is the bounds, and they are the reason it was ever thought about:
|
|
301
|
+
a body is read outside the deadline that fetched it, so an idle deadline, a
|
|
302
|
+
lifetime cap and a byte cap are what stop it being an unbounded socket.
|
|
303
|
+
|
|
304
|
+
## Declaring the upstreams you reach
|
|
305
|
+
|
|
306
|
+
`permissions.network` is a list of bare hostnames, and a leading `*.` is a
|
|
307
|
+
wildcard subdomain that does not match the apex:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
network: ['api.spotify.com', 'accounts.spotify.com'];
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Use the object form when the upstream publishes a rate limit. `host.fetch`
|
|
314
|
+
then paces you at it, parking each call until there is headroom instead of
|
|
315
|
+
failing it, and you write no pacer at all:
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
network: [
|
|
319
|
+
{ host: 'musicbrainz.org', ratePerSecond: 1, bucket: 'musicbrainz' },
|
|
320
|
+
{ host: '*.musicbrainz.org', ratePerSecond: 1, bucket: 'musicbrainz' },
|
|
321
|
+
'coverartarchive.org',
|
|
322
|
+
];
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`bucket` is what makes those first two entries share one allowance. Published
|
|
326
|
+
limits are usually per service rather than per hostname, and two entries
|
|
327
|
+
without a shared bucket are two allowances, which is how a plugin ends up at
|
|
328
|
+
twice the rate it declared and the station gets blocked. Entries with different
|
|
329
|
+
buckets never pace each other, so a slow upstream does not hold up a fast one.
|
|
330
|
+
|
|
331
|
+
When the operator picks the address (a mirror, a self-hosted server), name the
|
|
332
|
+
config field it lives in instead of a hostname:
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
configFields: [{ key: 'baseUrl', label: 'Server URL', type: 'url' }],
|
|
336
|
+
permissions: {
|
|
337
|
+
network: [
|
|
338
|
+
{ host: 'musicbrainz.org', ratePerSecond: 1, bucket: 'musicbrainz' },
|
|
339
|
+
{ fromConfig: 'baseUrl', ratePerSecond: 10 },
|
|
340
|
+
],
|
|
341
|
+
...
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
The host reads the hostname out of that setting when your plugin is
|
|
346
|
+
initialized, so changing it takes effect on the reinitialization the save
|
|
347
|
+
triggers. A setting that is blank or unparseable contributes no entry, so an
|
|
348
|
+
unconfigured plugin is refused exactly as if the host were undeclared, and a
|
|
349
|
+
wildcard is never accepted from a setting: those come only from a manifest an
|
|
350
|
+
operator could read before installing.
|
|
351
|
+
|
|
352
|
+
Order it after the hostname you know, as above. First match wins, so an
|
|
353
|
+
operator who points `baseUrl` back at the canonical service still gets the
|
|
354
|
+
strict rate rather than the mirror's.
|
|
355
|
+
|
|
356
|
+
A setting holding SEVERAL addresses contributes one entry each, which is how a
|
|
357
|
+
plugin pointed at a list the operator pasted — a reader of feeds — declares
|
|
358
|
+
upstreams it cannot know at authoring time. One address per line (a `text`
|
|
359
|
+
field), or the JSON array a `multiselect` stores, and where a line carries more
|
|
360
|
+
than the address the address is its last `|`-separated field:
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
configFields: [{ key: 'feeds', label: 'Feeds', type: 'text' }],
|
|
364
|
+
permissions: {
|
|
365
|
+
network: [{ fromConfig: 'feeds', ratePerSecond: 1, bucket: 'rss' }],
|
|
366
|
+
...
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
https://example.com/rss.xml
|
|
372
|
+
world|World news|https://example.com/world.xml
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Every rule above is applied per address rather than to the value as a whole, so
|
|
376
|
+
one mistyped line costs its own upstream and not the rest. Repeats collapse into
|
|
377
|
+
one entry, or two feeds at one publisher would install a second limiter and
|
|
378
|
+
quietly double the rate you asked to be paced at. A shared `bucket` is usually
|
|
379
|
+
right here, because what is being paced is your own outbound rate rather than
|
|
380
|
+
any one publisher's published limit.
|
|
381
|
+
|
|
382
|
+
The rate is a floor on the interval, not a promise of throughput: the host caps
|
|
383
|
+
every bucket at its own ceiling, so asking to go faster than the host allows
|
|
384
|
+
does nothing. Parking is spent from the call's budget, which is the other half
|
|
385
|
+
of this: see below.
|
|
386
|
+
|
|
387
|
+
## Knowing how much time you have
|
|
388
|
+
|
|
389
|
+
Every call into your code has a deadline, and it is not a constant: a
|
|
390
|
+
background job may run on a far longer budget than a request someone is waiting
|
|
391
|
+
on, and the same method of yours gets called both ways. Two things tell you
|
|
392
|
+
about it, and they answer different questions.
|
|
393
|
+
|
|
394
|
+
`host.signal` is an `AbortSignal` that fires when the host gives up on the call.
|
|
395
|
+
It is the host's own signal, not a copy, so honouring it and being abandoned are
|
|
396
|
+
the same moment rather than two clocks that nearly agree. `host.fetch` watches
|
|
397
|
+
it for you; pass it on to anything else of yours that takes one. Outside any
|
|
398
|
+
host call (from a timer you set yourself) it is a signal that never aborts,
|
|
399
|
+
because nothing is waiting on that work.
|
|
400
|
+
|
|
401
|
+
`host.remainingMs()` is the number behind it, for deciding whether to START
|
|
402
|
+
something rather than for being interrupted during it. Reach for it when the
|
|
403
|
+
work is a sequence whose later steps are optional, which is the usual shape for
|
|
404
|
+
anything paced against a rate-limited API:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
const core = await this.lookup(ref);
|
|
408
|
+
if (host.remainingMs() < 2_000) return core; // good enough, out of time
|
|
409
|
+
return { ...core, ...(await this.enrich(core)) };
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
What it saves you from is hardcoding a guess at the host's deadline, which
|
|
413
|
+
means either quitting early on a budget you actually had or being killed
|
|
414
|
+
halfway through with nothing to return.
|
|
415
|
+
|
|
416
|
+
It is the host's clock, not an allowance: everything the call does spends from
|
|
417
|
+
it, including the time `host.fetch` parks waiting for rate-limit headroom, and
|
|
418
|
+
`host.fetch` caps its own timeout by it. Read a small number as advice to wrap
|
|
419
|
+
up, not as permission to run that long.
|
|
420
|
+
|
|
421
|
+
## When your audio needs a helper to fetch it
|
|
422
|
+
|
|
423
|
+
Almost every provider answers `resolveStreamUrl` out of its own head: it knows a
|
|
424
|
+
URL, it signs one, it hands it back. `host.trackFetcher` is for the one shape
|
|
425
|
+
that cannot — audio that is reachable, but only to a process speaking a protocol
|
|
426
|
+
you do not.
|
|
427
|
+
|
|
428
|
+
Spotify is the case it exists for. Its tracks come off the CDN encrypted, so
|
|
429
|
+
there is no URL to mint at all; a separate binary runs beside the audio player,
|
|
430
|
+
speaks Spotify's own protocol, and re-serves the track as plain audio over HTTP.
|
|
431
|
+
You lend it a login, and get back the URL you were going to return anyway:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
async resolveStreamUrl(trackId: string): Promise<ProviderStream | undefined> {
|
|
435
|
+
const session = await this.currentSession(); // your account, your refresh
|
|
436
|
+
if (!session) return undefined; // not connected yet
|
|
437
|
+
|
|
438
|
+
return host.trackFetcher.serve({ trackId, session });
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
The login goes to the fetcher and nowhere else: it is not stored, not written to
|
|
443
|
+
config, and not readable back out of the host. `serve` resolves to `undefined`
|
|
444
|
+
when the operator's station has no fetcher configured, which you pass straight
|
|
445
|
+
through — an item nobody can resolve is skipped, not an error.
|
|
446
|
+
|
|
447
|
+
Requires the `trackFetcher` permission. **Do not reach for it when your audio can
|
|
448
|
+
simply be fetched.** Mint the URL yourself and keep your credentials to yourself,
|
|
449
|
+
which is both simpler and narrower.
|
|
450
|
+
|
|
451
|
+
Declaring the permission is also what puts the console's playback authorization
|
|
452
|
+
card on your plugin's page, where the operator authorizes the fetcher itself.
|
|
453
|
+
Declaring `stream` does not: a plugin that mints its own URLs has nothing there to
|
|
454
|
+
authorize.
|
|
455
|
+
|
|
456
|
+
## Enriching artists and albums, not just tracks
|
|
457
|
+
|
|
458
|
+
`enrichTrack` is the only method an enrichment plugin must write.
|
|
459
|
+
`enrichArtist` and `enrichAlbum` are optional, and worth writing for anything
|
|
460
|
+
that belongs to the artist or the record rather than to one recording:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
async enrichArtist(ref: ArtistRef): Promise<Partial<ArtistEnrichment>> {
|
|
464
|
+
// `mbid` is the id that crosses providers; `providerRef` is your own id
|
|
465
|
+
// from the last time you answered about this artist. Both may be absent
|
|
466
|
+
// the first time anything asks, in which case you have a name.
|
|
467
|
+
const id = ref.providerRef ?? (await this.search(ref.name));
|
|
468
|
+
if (!id) return {};
|
|
469
|
+
|
|
470
|
+
const body = jsonBody<{ bio?: string; image?: string }>(await this.request(`/artists/${id}`));
|
|
471
|
+
return { providerRef: id, biography: body.bio, imageUrl: body.image, externalIds: [{ source: 'recordbin', id }] };
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
The reason to split them is cost, not tidiness. The host asks once per artist
|
|
476
|
+
and once per album, so an artist who appears on forty tracks is one request
|
|
477
|
+
rather than forty, and the answer is stored against the artist where the
|
|
478
|
+
console and the DJ can both read it. Anything you return from `enrichTrack`
|
|
479
|
+
is still stored against the track, so a single with no album, or a
|
|
480
|
+
compilation whose tracks were licensed separately, is still described
|
|
481
|
+
correctly by `TrackEnrichment.label`.
|
|
482
|
+
|
|
483
|
+
The `providerRef` you return is remembered as the id that answer was fetched
|
|
484
|
+
under, and handed back to you — and only to you — as `ref.providerRef` next
|
|
485
|
+
time. That is what turns a second pass into a lookup instead of another search,
|
|
486
|
+
so state it whenever you have one.
|
|
487
|
+
|
|
488
|
+
It is deliberately separate from `externalIds`, which answers a different
|
|
489
|
+
question: what this thing is called _elsewhere_. List those in whatever order
|
|
490
|
+
you like, including ids that are not yours. A local library that reads a
|
|
491
|
+
MusicBrainz id out of a file's tags should absolutely report it, and doing so
|
|
492
|
+
must not cost it its own ref.
|
|
493
|
+
|
|
494
|
+
## Facts, and the prose facts are extracted from
|
|
495
|
+
|
|
496
|
+
`facts` and `documents` are both things to say about a record, and the
|
|
497
|
+
difference between them is who wrote the sentence.
|
|
498
|
+
|
|
499
|
+
A **fact** is a line you composed and are willing to have read out on air
|
|
500
|
+
unchanged. Keep them short and independently speakable, because that is what
|
|
501
|
+
happens to them: a talk break is shown a couple of them and the DJ works one
|
|
502
|
+
in. Compose them only out of things you actually know — a fact assembled from
|
|
503
|
+
a field you guessed at is a station saying something untrue in a confident
|
|
504
|
+
voice.
|
|
505
|
+
|
|
506
|
+
A **document** is somebody else's prose, verbatim: an encyclopaedia article, a
|
|
507
|
+
song description, a set of sleeve notes. Nothing reads one aloud and nothing
|
|
508
|
+
renders one on a page. The host extracts claims from it, checks each claim
|
|
509
|
+
against the text it came from, and keeps the citation. So:
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
return {
|
|
513
|
+
documents: [{ url: article.url, title: article.title, text: article.extract, retrievedAt: new Date().toISOString() }],
|
|
514
|
+
};
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Three rules make that worth doing.
|
|
518
|
+
|
|
519
|
+
**Hand over the prose, not your summary of it.** The host stores the document,
|
|
520
|
+
so a better extraction later costs your upstream nothing, and a claim's quoted
|
|
521
|
+
span has to occur in the text you supplied or the claim is dropped. A summary
|
|
522
|
+
you wrote is a span nobody can check.
|
|
523
|
+
|
|
524
|
+
**Plain text.** Strip the furniture a reader ignores anyway: navigation,
|
|
525
|
+
licence footers, reference markers, markup. What is left reaches a language
|
|
526
|
+
model, and eventually a mouth.
|
|
527
|
+
|
|
528
|
+
**`url` is a citation, not a link.** It is the address an operator opens to
|
|
529
|
+
check whether the station is telling the truth about a record, so it has to be
|
|
530
|
+
somewhere a person can actually read the text you sent. A document whose URL
|
|
531
|
+
is not http(s) is dropped whole.
|
|
532
|
+
|
|
533
|
+
## Searching the open web
|
|
534
|
+
|
|
535
|
+
A `search` plugin has one method, and the caller supplies the subject:
|
|
536
|
+
|
|
537
|
+
```ts
|
|
538
|
+
async search(query: SearchQuery): Promise<SearchResult[]> {
|
|
539
|
+
const hits = await this.engine.run(query.query, query.limit, query.recency);
|
|
540
|
+
return hits.map(hit => ({ title: hit.title, snippet: plainText(hit.description), url: hit.url, site: hit.profile }));
|
|
541
|
+
}
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
Three things about it are easy to get wrong.
|
|
545
|
+
|
|
546
|
+
**A snippet is plain text.** Search APIs are the worst offenders in the SDK for
|
|
547
|
+
this: Brave wraps every matched query term in `<strong>` unless you pass
|
|
548
|
+
`text_decorations=0`, and descriptions carry HTML entities either way. Run them
|
|
549
|
+
through `plainText` from `html.text.ts`. What is left reaches a language model,
|
|
550
|
+
and possibly a mouth.
|
|
551
|
+
|
|
552
|
+
**There is nowhere to put a synthesized answer, deliberately.** Several engines
|
|
553
|
+
sell one — Tavily's `answer`, SearXNG's first infobox — and it is a paragraph
|
|
554
|
+
somebody else's model wrote about pages this station never sees. Nothing can
|
|
555
|
+
check it against a source, so nothing here can carry it. If your plugin has
|
|
556
|
+
prose worth extracting claims from, declare `enrichment` too and return it as a
|
|
557
|
+
`documents` entry, where the provenance and the quote check already live.
|
|
558
|
+
|
|
559
|
+
**An empty array is an answer.** An unconfigured plugin, an engine that is down,
|
|
560
|
+
a rate limit and a query nothing matched are one outcome to every caller. Throw
|
|
561
|
+
only for something the operator has to go and fix.
|
|
562
|
+
|
|
563
|
+
Note what `search` is not. Looking for something to PLAY is `searchTracks` on
|
|
564
|
+
the catalog capability, which answers with provider ids the station can resolve
|
|
565
|
+
into audio. Nothing a search plugin returns can be scheduled.
|
|
566
|
+
|
|
567
|
+
## Saying what it is like outside
|
|
568
|
+
|
|
569
|
+
A `weather` plugin has one method, and the caller supplies the place:
|
|
570
|
+
|
|
571
|
+
```ts
|
|
572
|
+
async getWeather(query: WeatherQuery): Promise<WeatherReading | undefined> {
|
|
573
|
+
const point = await this.resolve(query.place); // your service, your geocoder
|
|
574
|
+
if (point === undefined) return undefined; // nowhere of that name
|
|
575
|
+
|
|
576
|
+
const forecast = await this.forecast(point, query.days ?? 0);
|
|
577
|
+
return { place: point.name, observedAt: forecast.time, current: forecast.now, days: forecast.days };
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Three things about it are easy to get wrong.
|
|
582
|
+
|
|
583
|
+
**Everything is metric.** Celsius, km/h, and no unit field anywhere. What a
|
|
584
|
+
station SAYS is a station's own decision, settled where the words are made,
|
|
585
|
+
beside every other decision about how that station talks. A plugin converting
|
|
586
|
+
would make the units on the wire depend on which plugin was installed, which is
|
|
587
|
+
the one thing a capability must not let happen. It is `ConfigField.unit`'s rule
|
|
588
|
+
one layer down.
|
|
589
|
+
|
|
590
|
+
**You resolve the place, and the host never geocodes.** `query.place` is a name
|
|
591
|
+
somebody typed. Turning it into coordinates is exactly the per-service quirk this
|
|
592
|
+
boundary exists to absorb — one service ships a geocoder, one takes coordinates
|
|
593
|
+
only, one wants its own city ids — and a host that geocoded would have to pick
|
|
594
|
+
one service to geocode with. Answer with the place as YOUR service resolved it,
|
|
595
|
+
because that is the part a presenter says out loud and the only evidence anybody
|
|
596
|
+
has that the right town was found.
|
|
597
|
+
|
|
598
|
+
**A condition is a closed vocabulary.** `WeatherCondition` is ten arms. WMO code
|
|
599
|
+
73, an icon string of `snow` and a numeric condition id are three spellings of
|
|
600
|
+
one thing, and mapping them is yours. Your service's own word survives beside it
|
|
601
|
+
as `description`, which nothing deterministic reads and a model may use.
|
|
602
|
+
|
|
603
|
+
`undefined` is an answer, on the same terms as a search plugin's empty array: an
|
|
604
|
+
unconfigured plugin, a place nothing could resolve and a service that is down are
|
|
605
|
+
one outcome to every caller. Throw only for something the operator must fix.
|
|
606
|
+
|
|
607
|
+
## Music providers
|
|
608
|
+
|
|
609
|
+
A `music-provider` declares any subset of four sub-capabilities and lists the
|
|
610
|
+
ones it implements in `manifest.capabilities`:
|
|
611
|
+
|
|
612
|
+
- **`catalog`** — `searchTracks`, `getTrack`, `listPlaylists`,
|
|
613
|
+
`getPlaylistTracks`. Being browsable, and nothing more: a provider that can
|
|
614
|
+
be searched but whose audio deadair cannot get at is a legitimate thing to
|
|
615
|
+
be, and it declares this alone.
|
|
616
|
+
|
|
617
|
+
`SearchTracksOptions` carries `genre`, `yearFrom` and `yearTo` beside
|
|
618
|
+
`limit` and `offset`, structured and provider-neutral: the caller's `query`
|
|
619
|
+
is free text going at a title and an artist name, and a style is a
|
|
620
|
+
different axis. Express them however your upstream does — the host never
|
|
621
|
+
learns one service's filter dialect, exactly as it never learns a speech
|
|
622
|
+
engine's knobs.
|
|
623
|
+
|
|
624
|
+
**Two rules here are load-bearing, and both are about not lying quietly.**
|
|
625
|
+
A filter you cannot apply means you have nothing to offer for that search,
|
|
626
|
+
so answer `[]`; never ignore it and answer as though it had not been asked
|
|
627
|
+
for, because the caller merges several providers into one list and nothing
|
|
628
|
+
marks which rows honoured it. And `limit` is a TOTAL rather than a page
|
|
629
|
+
size: page internally if your upstream's own ceiling is lower, since a
|
|
630
|
+
short answer is indistinguishable from a genuinely thin search.
|
|
631
|
+
|
|
632
|
+
- **`stream`** — `resolveStreamUrl`: hand back a complete URL the audio consumer
|
|
633
|
+
can fetch directly, carrying its own authentication, because it is fetched
|
|
634
|
+
with no headers from us. A provider that cannot answer it plays its own audio
|
|
635
|
+
and declares `steer` instead.
|
|
636
|
+
|
|
637
|
+
How you get that URL is your business. Most providers mint one out of their
|
|
638
|
+
own head. If your audio is reachable only to a process speaking a protocol you
|
|
639
|
+
do not (Spotify's, whose tracks come off the CDN encrypted) lend the station's
|
|
640
|
+
fetcher a login and return the URL it gives you back; see _When your audio
|
|
641
|
+
needs a helper to fetch it_ above.
|
|
642
|
+
|
|
643
|
+
This capability is a URL, not bytes. Audio that reaches the station as bytes
|
|
644
|
+
through Node is the exceptional path and always was, even now that
|
|
645
|
+
`response.body` makes it trivial: the audio consumer is Liquidsoap in a
|
|
646
|
+
sibling container, so a URL it can fetch is zero copies and a stream through
|
|
647
|
+
here is two.
|
|
648
|
+
|
|
649
|
+
- **`steer`** — `enqueue`, `play`, `pause`, `skip`, `getPlaybackState`. The
|
|
650
|
+
provider owns the audio output and deadair only tells it what to do. Named
|
|
651
|
+
from the plugin's side on purpose: deadair's own `playout` module is the
|
|
652
|
+
opposite end of this, the one that owns the running order.
|
|
653
|
+
- **`oauth`** — `getAuthorizeUrl(state)` and `handleCallback(params)`. The host
|
|
654
|
+
owns the redirect endpoint (`host.oauth.getRedirectUri()`) and the token
|
|
655
|
+
vault (`saveTokens` / `getTokens`); you only build the authorize URL and
|
|
656
|
+
exchange the code.
|
|
657
|
+
|
|
658
|
+
```ts
|
|
659
|
+
import { definePlugin, type MusicProviderPluginInstance, Plugin, type ProviderTrack } from '@deadair/plugin-sdk';
|
|
660
|
+
|
|
661
|
+
class LibraryPlugin extends Plugin implements MusicProviderPluginInstance {
|
|
662
|
+
async searchTracks(query: string): Promise<ProviderTrack[]> {
|
|
663
|
+
// ...
|
|
664
|
+
return [];
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
## Speaking
|
|
670
|
+
|
|
671
|
+
A plugin that declares `speech` turns a line of text into audio. The result is a
|
|
672
|
+
stream rather than a value, and the usual implementation is to hand back the
|
|
673
|
+
engine's own response body:
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
class KokoroPlugin extends Plugin implements SpeechPluginInstance {
|
|
677
|
+
async speak({ text, voice }: SpeechRequest): Promise<SpeechHandle> {
|
|
678
|
+
const response = await this.host.fetch(`${this.baseUrl}/audio/speech`, {
|
|
679
|
+
method: 'POST',
|
|
680
|
+
headers: { 'content-type': 'application/json' },
|
|
681
|
+
body: JSON.stringify({ input: text, voice: this.voices[voice ?? ''] ?? this.defaultVoice }),
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
if (!response.ok || response.body === null) {
|
|
685
|
+
await response.body?.cancel().catch(() => {});
|
|
686
|
+
throw new PluginError(`TTS answered ${response.status}`).withCode('upstream');
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return { mime: 'audio/mpeg', audio: response.body };
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
That is the whole thing. The audio is never held whole on either side, the host
|
|
695
|
+
reads it or cancels it, and cancelling reaches the socket without this plugin
|
|
696
|
+
forwarding anything.
|
|
697
|
+
|
|
698
|
+
Three things that are easy to get wrong:
|
|
699
|
+
|
|
700
|
+
- **Let go of a refusal's body.** `speak` throws instead of handing it over, so
|
|
701
|
+
that `cancel()` is the only chance anything has to release it.
|
|
702
|
+
- **Check the size at the END, not on the first chunk.** A server can dribble a
|
|
703
|
+
short JSON error out in several pieces, so "was any of that plausibly audio"
|
|
704
|
+
is only answerable once the stream stops. A `TransformStream` that counts and
|
|
705
|
+
throws in `flush` is the shape that fits; failing there fails the render
|
|
706
|
+
loudly instead of storing a click.
|
|
707
|
+
- **`mime` is the answer, not the request.** `SpeechRequest.format` is a hint you
|
|
708
|
+
may ignore; what you return in `SpeechHandle.mime` is what the station stores
|
|
709
|
+
and later serves, and both consumers of station audio pick their behaviour from
|
|
710
|
+
that header rather than from the bytes.
|
|
711
|
+
|
|
712
|
+
### Voices
|
|
713
|
+
|
|
714
|
+
`SpeechRequest.voice` is an opaque id the operator chose (`host`, `newsreader`).
|
|
715
|
+
Map it to whatever your engine takes, out of your own config, and fall back to
|
|
716
|
+
your default for an id you do not know — a missing voice is worth a
|
|
717
|
+
`logger.warn` and a rendered line, not a silent station.
|
|
718
|
+
|
|
719
|
+
The host never interprets that string, and that is deliberate. It is what lets
|
|
720
|
+
one station voice be a named preset on one engine and a cloned reference clip on
|
|
721
|
+
another, so swapping engines does not rewrite every persona. Engine-specific
|
|
722
|
+
tuning belongs in your config, not in the request: the host should not be
|
|
723
|
+
carrying knobs only one implementation understands.
|
|
724
|
+
|
|
725
|
+
Implement `listVoices()` if you have more than one, so the console can draw a
|
|
726
|
+
list and preview them. It is optional, and a single-voice plugin is a legitimate
|
|
727
|
+
thing to be.
|
|
728
|
+
|
|
729
|
+
Set `SpeechVoice.spec` on each one. It is an opaque token the host does not read
|
|
730
|
+
— whatever identifies a RENDERING to you, like `af_heart@0.95` — and its only job
|
|
731
|
+
is keying the cached preview at `GET /voices/{id}/sample`. Without it that cache
|
|
732
|
+
is keyed on the station voice ID, which is exactly the part that does not change
|
|
733
|
+
when the operator remaps it underneath, so a remap serves the old voice back
|
|
734
|
+
forever. Leaving it out is safe for an engine whose voices cannot be
|
|
735
|
+
reconfigured, and wrong for one whose can.
|
|
736
|
+
|
|
737
|
+
**A voice map wants a `list` config field**, not a text box with a separator in
|
|
738
|
+
it. Declare a column for the station's name and one for your engine's, and
|
|
739
|
+
implement `suggestConfigOptions()` to fill the second from whatever the
|
|
740
|
+
operator's own server currently reports — published under `"<fieldKey>.<columnKey>"`.
|
|
741
|
+
That is the difference between a table somebody can complete and one that
|
|
742
|
+
requires knowing your engine's voice ids by heart, and the shipped plugins get it
|
|
743
|
+
wrong at your peril: one of them ran with an empty map against a server holding
|
|
744
|
+
68 voices for as long as the field was a box. Keep the column `string` rather
|
|
745
|
+
than `select` — a cell with choices renders as an autocomplete, so a value your
|
|
746
|
+
list cannot enumerate (a blend expression, a clip added a minute ago) stays
|
|
747
|
+
typeable.
|
|
748
|
+
|
|
749
|
+
## Producing words
|
|
750
|
+
|
|
751
|
+
A plugin that declares `llm` continues a conversation. It is a **transport, not a
|
|
752
|
+
writer**: nothing in this capability knows what a break, a show or a running
|
|
753
|
+
order is, because deciding what to say is the station's business and the shapes
|
|
754
|
+
that need saying keep multiplying.
|
|
755
|
+
|
|
756
|
+
```ts
|
|
757
|
+
class MyModelPlugin extends Plugin implements LlmPluginInstance {
|
|
758
|
+
async generate(request: LlmRequest): Promise<LlmHandle> {
|
|
759
|
+
const stream = streamText({
|
|
760
|
+
model: this.provider(request.model ?? this.defaultModel),
|
|
761
|
+
messages: toProviderMessages(request.messages),
|
|
762
|
+
...(request.tools === undefined ? {} : { tools: toProviderTools(request.tools) }),
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
text: stream.textStream,
|
|
767
|
+
result: buildResult(stream),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
The words come back as a stream for a stronger reason than memory: the host
|
|
774
|
+
serializes generations through one slot and holds it until the words stop
|
|
775
|
+
arriving, not until `generate` resolves. On a local model, releasing early lets
|
|
776
|
+
two generations overlap and both get slower.
|
|
777
|
+
|
|
778
|
+
A caller that only wants the answer uses `collectGeneration(handle)`, which
|
|
779
|
+
drains the stream and then returns the result. Reaching for `handle.result`
|
|
780
|
+
without draining `handle.text` is how a caller waits forever, because an
|
|
781
|
+
undrained provider stream applies backpressure.
|
|
782
|
+
|
|
783
|
+
Four things that are easy to get wrong:
|
|
784
|
+
|
|
785
|
+
- **`result.text` is the authority, not the chunks.** A plugin that buffers and
|
|
786
|
+
one that forwards are both legal, and only the first would agree with whatever
|
|
787
|
+
a caller concatenated off the stream.
|
|
788
|
+
- **Send `reasoning_effort` only when asked.** It means nothing to a plain model
|
|
789
|
+
and a strict OpenAI-compatible server answers 400 rather than ignoring it.
|
|
790
|
+
Absent means send nothing at all.
|
|
791
|
+
- **`request.model` overrides your configured one.** A station wants a big model
|
|
792
|
+
for a show and a small one for a station ident, and one plugin holds exactly
|
|
793
|
+
one config row, so the choice has to travel with the call.
|
|
794
|
+
- **Refuse tools you cannot do.** Throw `unsupported` rather than dropping them:
|
|
795
|
+
a break written without the facts a tool would have supplied is worse than one
|
|
796
|
+
that fell back to the deterministic writer.
|
|
797
|
+
- **A plugin holding several backends qualifies its ids.** `LlmModelInfo.id` is what comes back as
|
|
798
|
+
`LlmRequest.model`, so it has to be enough to route on: two backends ship models with similar
|
|
799
|
+
names, and the host does not inspect the string. Mark exactly one entry `default: true`, whichever
|
|
800
|
+
one an unnamed request will actually reach. If the backends are rows an operator adds, a `secret`
|
|
801
|
+
column keeps each credential in its own row — see **Rows, and a credential inside one**.
|
|
802
|
+
- **Carry back what the provider signed.** Put it in `LlmResult.providerState`
|
|
803
|
+
and the host quotes it verbatim onto the `assistant` turn it builds, as
|
|
804
|
+
`LlmMessage.providerState`, without ever reading it. Some providers refuse a
|
|
805
|
+
tool round trip whose earlier turns arrive stripped of their own thinking
|
|
806
|
+
blocks or call signatures, and that is a fact about a wire protocol rather than
|
|
807
|
+
about a conversation. Leave it unset if yours signs nothing, which most do.
|
|
808
|
+
|
|
809
|
+
### Tools
|
|
810
|
+
|
|
811
|
+
`LlmRequest.tools` are **declarations**, and what comes back in
|
|
812
|
+
`LlmResult.toolCalls` is **data**. The host runs the tool and sends the result
|
|
813
|
+
back as another message; nothing executable crosses this boundary in either
|
|
814
|
+
direction, which is why every shape here except `LlmHandle` is JSON-safe.
|
|
815
|
+
|
|
816
|
+
When you replay a conversation, an `assistant` turn that asked for a tool must
|
|
817
|
+
carry its `toolCalls`, and the `tool` turn answering it must carry the matching
|
|
818
|
+
`toolCallId`. A model that cannot see its own call has no idea what the message
|
|
819
|
+
after it is answering.
|
|
820
|
+
|
|
821
|
+
Implement `listModels()` if you want tools to work at all. Tool support is a
|
|
822
|
+
property of the **model**, not the server — one endpoint commonly serves both a
|
|
823
|
+
model that can call tools and one that cannot — so the host reads
|
|
824
|
+
`LlmModelInfo.tools` to decide whether it may send any. With no `listModels`, it
|
|
825
|
+
has no way to learn that and sends none.
|
|
826
|
+
|
|
827
|
+
Mark one entry `default: true`. A request that names no model gets yours, and
|
|
828
|
+
without the mark the host cannot tell which of your models that is: it has to
|
|
829
|
+
assume the least capable one, so a server with a dozen installed never gets sent
|
|
830
|
+
tools at all.
|
|
831
|
+
|
|
832
|
+
Two things worth separating when you write it. **Which models exist is usually
|
|
833
|
+
discoverable** — ask the server, rather than making an operator type out what the
|
|
834
|
+
machine already knows. **Which of them accept tools is not**, and no endpoint
|
|
835
|
+
reports it, so that part has to be config. Getting this backwards produces a
|
|
836
|
+
setup loop with no way in: an operator cannot name a model before they can reach
|
|
837
|
+
the server, and cannot test the server before they have saved it. Let the address
|
|
838
|
+
be saved on its own, and say the model names in `testConnection` — for many
|
|
839
|
+
plugins it is the only place an operator can learn them.
|
|
840
|
+
|
|
841
|
+
## Decoded audio
|
|
842
|
+
|
|
843
|
+
`analysis` and `mixer` are the two capabilities that need decoded PCM, which is
|
|
844
|
+
the one thing that does not happen inside deadair. The expected shape for both is
|
|
845
|
+
an adapter over a separate program — the bundled one is an HTTP sidecar serving
|
|
846
|
+
both — in the same relationship a speech plugin has with its engine.
|
|
847
|
+
|
|
848
|
+
### Measuring, and joining
|
|
849
|
+
|
|
850
|
+
`analyzeTrack(ref)` is what `analysis` requires: bytes in, cue points and
|
|
851
|
+
loudness out. Two fields in the answer are not measurements and both matter.
|
|
852
|
+
`schemaVersion`
|
|
853
|
+
is what YOU produced rather than the constant this package exports, because an
|
|
854
|
+
adapter is reporting the analyzer's version and the two drift across an upgrade.
|
|
855
|
+
`complete` says whether the whole file was measured, and the host cannot check
|
|
856
|
+
it: a truncated download measures perfectly confidently and the specific lie it
|
|
857
|
+
tells is that a record which fades ended cold. Always answering `true` disables
|
|
858
|
+
the check silently.
|
|
859
|
+
|
|
860
|
+
`join(request)` is what `mixer` requires: several URLs and a gap in, one piece of
|
|
861
|
+
audio out, as a `mime` and a stream. Trim each part to its own cue points unless
|
|
862
|
+
told not to, and put the silence BETWEEN the parts and never at the ends — what
|
|
863
|
+
you are making is one item in somebody's running order. Answer `unsupported`
|
|
864
|
+
rather than failing where the thing behind you cannot join what it was given; a
|
|
865
|
+
station that asks has somewhere to go, since a programme whose parts were not
|
|
866
|
+
joined simply airs as its parts.
|
|
867
|
+
|
|
868
|
+
### Why these are two capabilities and can still be one plugin
|
|
869
|
+
|
|
870
|
+
Joining is the same requirement seen from the other end: whatever decodes for you
|
|
871
|
+
can almost certainly concatenate. So declare both and serve them off one address,
|
|
872
|
+
which is what the bundled adapter does — splitting it would be two config rows
|
|
873
|
+
for one process, free to drift apart.
|
|
874
|
+
|
|
875
|
+
They are two capabilities all the same, because **the host picks one plugin per
|
|
876
|
+
capability**. Carried on `analysis` as an optional method, the station's joiner
|
|
877
|
+
was whichever plugin the operator chose to MEASURE with: install one that
|
|
878
|
+
measures better and cannot join, name it, and joining stops with nothing to do
|
|
879
|
+
about it but choose a worse analyzer. Two keys (`analysis.pluginId` and
|
|
880
|
+
`render.mixerPluginId`) let a station measure with one engine and join with
|
|
881
|
+
another, and let a mix-only plugin exist at all.
|
|
882
|
+
|
|
883
|
+
Declaring several capabilities is ordinary here rather than a compromise: the
|
|
884
|
+
bundled music providers declare three and four.
|
|
885
|
+
|
|
886
|
+
## Configuration fields
|
|
887
|
+
|
|
888
|
+
`configFields` is a declarative form description. The host renders it; plugins
|
|
889
|
+
never ship UI.
|
|
890
|
+
|
|
891
|
+
| type | notes |
|
|
892
|
+
| ------------- | ----------------------------------------------------------- |
|
|
893
|
+
| `string` | free text |
|
|
894
|
+
| `text` | free text over several lines |
|
|
895
|
+
| `url` | free text, validated as a URL |
|
|
896
|
+
| `secret` | write-only, encrypted, read via `host.secrets.get()` |
|
|
897
|
+
| `number` | numeric input, bounded by `min` / `max` if declared |
|
|
898
|
+
| `boolean` | toggle |
|
|
899
|
+
| `select` | one of `options` |
|
|
900
|
+
| `multiselect` | any number of `options`, stored as a JSON array |
|
|
901
|
+
| `list` | any number of rows over `columns`; read with `parseRows()` |
|
|
902
|
+
| `note` | not an input; static help text in the form |
|
|
903
|
+
|
|
904
|
+
Use `dependsOn` to hide a field until another one is filled in. Use `min` and
|
|
905
|
+
`max` on a `number` to say what it will take, which the form bounds the input to.
|
|
906
|
+
Use `configSchema` for anything the form cannot express: the host parses the
|
|
907
|
+
operator's submission with it before storing, so by the time `onLoad()` runs your
|
|
908
|
+
config is already valid. Read a `multiselect` back with
|
|
909
|
+
`parseMultiSelect(config.myField)`.
|
|
910
|
+
|
|
911
|
+
### Rows, and a credential inside one
|
|
912
|
+
|
|
913
|
+
A `list` is a table the operator adds rows to, declared with `columns` and stored
|
|
914
|
+
as a JSON array of objects. A column is `string`, `url`, `select` or `secret`.
|
|
915
|
+
|
|
916
|
+
```ts
|
|
917
|
+
{ key: 'providers', label: 'Providers', type: 'list', columns: [
|
|
918
|
+
{ key: 'name', label: 'Name', type: 'string', required: true },
|
|
919
|
+
{ key: 'baseUrl', label: 'Address', type: 'url' },
|
|
920
|
+
{ key: 'apiKey', label: 'API key', type: 'secret' },
|
|
921
|
+
]}
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
A `secret` cell behaves exactly as a `secret` field does and for the same reasons:
|
|
925
|
+
the console never shows it, the API never returns it, and it is encrypted on its
|
|
926
|
+
own. **It is not in the row.** `parseRows` gives you the ordinary cells, and the
|
|
927
|
+
credential is fetched separately:
|
|
928
|
+
|
|
929
|
+
```ts
|
|
930
|
+
for (const row of parseRows(config.providers)) {
|
|
931
|
+
const apiKey = await readRowSecret(this.host, 'providers', row, 'apiKey');
|
|
932
|
+
}
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
Every row also carries `ROW_ID_KEY` (`$id`), minted by the host the first time the
|
|
936
|
+
row is saved and stable across reorders and edits. It exists so a ciphertext can
|
|
937
|
+
belong to a row rather than to a position in an array the console rewrites whole
|
|
938
|
+
on every save. Ignore it and nothing changes; `readRowSecret` is the only thing
|
|
939
|
+
that reads it.
|
|
940
|
+
|
|
941
|
+
One constraint falls out of that: a field key and a column key may not contain a
|
|
942
|
+
`/`, because `rowSecretKey` joins on it. The manifest schema refuses one, so you
|
|
943
|
+
find out at load rather than at save.
|
|
944
|
+
|
|
945
|
+
### A column that only some rows have
|
|
946
|
+
|
|
947
|
+
Where a table's columns are not all about the same row — a provider reached at an
|
|
948
|
+
address the operator runs, beside one reached where its vendor lives — a column
|
|
949
|
+
says which rows it applies to:
|
|
950
|
+
|
|
951
|
+
```ts
|
|
952
|
+
{ key: 'kind', label: 'Kind', type: 'select', options: [...] },
|
|
953
|
+
{ key: 'baseUrl', label: 'Address', type: 'url', dependsOn: 'kind', dependsOnValues: ['server'] },
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
`dependsOn` names another column in the same list and `dependsOnValues` the values
|
|
957
|
+
of that cell this one applies to; omit the values and any non-empty value will do,
|
|
958
|
+
which is what a field's `dependsOn` already means.
|
|
959
|
+
|
|
960
|
+
This says more than the field-level `dependsOn` does, and the difference is worth
|
|
961
|
+
knowing. A field's `dependsOn` hides a control and the server never reads it. A
|
|
962
|
+
column's says the cell **does not apply**: the console will not draw it or send
|
|
963
|
+
it, and the host will not derive anything from it — so a `url` column that does
|
|
964
|
+
not apply to a row contributes no hostname to your `network` allowlist. An
|
|
965
|
+
address left behind on a row whose kind was changed would otherwise widen that
|
|
966
|
+
allowlist by a host you can never call.
|
|
967
|
+
|
|
968
|
+
Forgiving where being strict would cost you: a target the list does not declare
|
|
969
|
+
shows the cell, a target cell that is still empty shows the cell, and values with
|
|
970
|
+
no target are ignored. The empty case is the one to keep in mind — a column has
|
|
971
|
+
no `default`, so a row somebody has just added has an empty cell everywhere, and
|
|
972
|
+
the whole row stays fillable until they say what kind of thing it is. A `secret`
|
|
973
|
+
cell that stops applying keeps whatever is stored; nothing here deletes a
|
|
974
|
+
credential.
|
|
975
|
+
|
|
976
|
+
### Asking for a better control
|
|
977
|
+
|
|
978
|
+
`control` says how a field should be DRAWN where the ordinary input for its type
|
|
979
|
+
reads badly. It never changes what is stored.
|
|
980
|
+
|
|
981
|
+
| control | on | what it draws |
|
|
982
|
+
| -------- | -------- | ---------------------------------------------------------------- |
|
|
983
|
+
| `slider` | `number` | a track, using `min`, `max` and `step`. Both bounds are required |
|
|
984
|
+
| `tags` | `string` | chips over a comma-separated line, split and joined by the form |
|
|
985
|
+
|
|
986
|
+
Both are opt-in per field rather than inferred, and the reason is the same each
|
|
987
|
+
time: a slider is right for a value somebody feels for (a percentage, a trim in
|
|
988
|
+
decibels) and wrong for one they have to hit exactly, since 3500 out of 0 to
|
|
989
|
+
600000 is a pixel. `tags` suits a set of short names and not a value that can
|
|
990
|
+
contain a comma. A field that asks for a control it cannot have — a `slider`
|
|
991
|
+
with an open end — falls back to the ordinary input rather than failing, because
|
|
992
|
+
this is a hint about drawing and a spinner beats a blank space.
|
|
993
|
+
|
|
994
|
+
`unit` is the same idea one step further, for a `number` whose stored unit is
|
|
995
|
+
not the one a person means: `bytes` is typed in gigabytes, and `fraction` holds
|
|
996
|
+
a share between 0 and 1 and is shown as a percentage. The value on the wire is
|
|
997
|
+
always the declared unit, so nothing downstream learns that the console converts.
|
|
998
|
+
|
|
999
|
+
`rangeWith` names the `number` field that is the upper end of the range this one
|
|
1000
|
+
opens, and is declared on the lower end. They stay two fields with two keys and
|
|
1001
|
+
two independent validations; what it buys is one control with two handles,
|
|
1002
|
+
instead of a relationship that lives only in two labels. Declare it where the
|
|
1003
|
+
range is narrow enough that the whole track is usable — a pair bounded by a
|
|
1004
|
+
sanity guard rather than by intent leaves both handles bunched at one end.
|
|
1005
|
+
|
|
1006
|
+
Note what `min`/`max` are and are not for a plugin. The host stores what it is
|
|
1007
|
+
handed and **your `configSchema` is what judges it**, so these bound the control
|
|
1008
|
+
rather than the value: they belong on a field whose range is a fact about your
|
|
1009
|
+
upstream, and they do not replace a schema. (The station's own settings use the
|
|
1010
|
+
same descriptor and do enforce them, because the settings route is their only
|
|
1011
|
+
writer.)
|
|
1012
|
+
|
|
1013
|
+
### Choices your server decides
|
|
1014
|
+
|
|
1015
|
+
`options` is fixed when the manifest is written, which is fine for a closed set
|
|
1016
|
+
and useless for anything the operator's own server knows. Implement
|
|
1017
|
+
`suggestConfigOptions()` and the form asks you what to offer:
|
|
1018
|
+
|
|
1019
|
+
```ts
|
|
1020
|
+
async suggestConfigOptions(): Promise<Record<string, ConfigFieldOption[]>> {
|
|
1021
|
+
const models = await this.fetchModels();
|
|
1022
|
+
return { model: models.map(id => ({ value: id, label: id })) };
|
|
1023
|
+
}
|
|
1024
|
+
```
|
|
1025
|
+
|
|
1026
|
+
Implementing it is the whole opt-in — there is nothing to declare on the field.
|
|
1027
|
+
Return a map so the form costs one call however many fields you have, and leave
|
|
1028
|
+
out a key you have nothing to say about.
|
|
1029
|
+
|
|
1030
|
+
What the operator sees depends on the field's type. A `string` or `url` becomes
|
|
1031
|
+
free text **with** suggestions, so a value you could not enumerate is still
|
|
1032
|
+
typeable; a `select` or `multiselect` has its declared options replaced. There is
|
|
1033
|
+
a refresh control either way, and a field whose suggestions failed is still a
|
|
1034
|
+
field somebody can use.
|
|
1035
|
+
|
|
1036
|
+
Two things to get right, because the failure is a setup loop with no way in:
|
|
1037
|
+
|
|
1038
|
+
- **Do not require a field whose value can only be learned from the server.** It
|
|
1039
|
+
runs against your SAVED config, like `testConnection` does, so the operator has
|
|
1040
|
+
to be able to save the address before they can be told what is on it.
|
|
1041
|
+
- **Answer with what you have rather than throwing.** An unreachable upstream
|
|
1042
|
+
should cost the dropdown, not the form.
|
|
1043
|
+
|
|
1044
|
+
## Versioning
|
|
1045
|
+
|
|
1046
|
+
`PLUGIN_API_VERSION` is the API version this SDK implements. Your manifest's
|
|
1047
|
+
`apiVersion` is a semver **range** (`^1.0.0`), and the host refuses to load a
|
|
1048
|
+
plugin whose range does not cover its own version.
|
|
1049
|
+
|
|
1050
|
+
That is one number, and the package's version is another. `@deadair/plugin-sdk`
|
|
1051
|
+
is released with the station and carries the station's version, so SDK `0.4.2`
|
|
1052
|
+
is exactly the SDK that station `0.4.2` runs. It moves on every station release
|
|
1053
|
+
whether or not anything here changed, which is why a plugin's peer range is a
|
|
1054
|
+
floor (`>=0.1.0`) and never a caret: `^0.1.0` would refuse the next station
|
|
1055
|
+
minor for no reason. `PLUGIN_API_VERSION` moves only when the contract does, and
|
|
1056
|
+
a major step there is the only thing that ever makes a working plugin stop
|
|
1057
|
+
loading.
|
|
1058
|
+
|
|
1059
|
+
The published `package.json` lists two `@repo/config-*` packages at `0.0.0` among
|
|
1060
|
+
its devDependencies. They are this repository's shared lint and compiler
|
|
1061
|
+
configs, are never installed by anybody who depends on the SDK, and are not on
|
|
1062
|
+
npm.
|