@vestra-player/sdk 1.5.2 → 1.5.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.3
4
+
5
+ **File resolvers: local files as generated streams** (player 0.4.1). Additive; `apiVersion` stays `vestra/1`. Plugins that register one set `"minPlayerVersion": "0.4.1"`: an older player refuses the declaration (`invalid`).
6
+
7
+ - **`resolvers.add({ id, extensions, resolve })`** (`FileResolverOptions`). When anyone opens a local file of those extensions with `player.open` (the Open dialog, the playlist, next / previous, the recent list), the player asks this plugin for a stream instead of playing the file. `resolve(path, signal)` returns a `ResolvedStream`: `{ kind: "stream", width, height, frame, started? }`, with the frame contract of `openStream`. Needs `source.resolve` and `producer.av`. `ResolverOptions` is now the union of `WebResolverOptions` (the existing form) and `FileResolverOptions`.
8
+ - The open keeps its arbitration: its `ifCurrent` and operation (the opener's `open` / `media.ready()` settle as usual), and `paused` reaches the stream's start. `state.source` stays the path, `state.kind` is `"stream"`.
9
+ - Cancellation: `signal` aborts when the open is replaced or closed (`player.close()` included), times out (30 s) or the resolver is disposed. Cancelled after `resolve` returned but before the stream ran, the SDK releases the stream and never calls `started`.
10
+ - `started(stream)` is called synchronously right after activation, before any `onStop`; the `Stream` is the same as `openStream`'s and belongs to the resolver's plugin.
11
+ - Extensions: 1–16 entries of 1–16 lower-case letters or digits, not `"folder"`; only absolute local paths match (any case), URLs never do.
12
+ - **One extension, and one scheme + host pair, has one resolver.** Another plugin's makes `Resolver.ready` reject with `conflict` (reason `resolver-taken` on the wire), and that no longer fails activation, for web resolvers too (as for panels and the sequence since 1.5.2); the player shows a warning (`PluginInfo.warnings`: `code` `"resolver-taken"`, `owner`, the new `target`). The plugin's own second claim still fails activation.
13
+ - **Resolver failures are sent as the error's message** (`error.message`, was `String(error)`, e.g. `"Error: …"`), and the player shows them word for word (it prefixed `resolver failed: `). This changes the failure text of web resolvers too.
14
+ - **Docs**: `PlayerState.source` documents the naming of generated streams: `producer://<n>` (opaque) for `openStream`, the path for a file resolver's; tell streams apart by `kind`.
15
+ - **`TestHost`** simulates file resolutions: opening a matching path (`player.open`, `openElsewhere`) sends `source.resolveRequest` and keeps the open pending; the answer selects the stream (or fails the open); any later source change cancels it (`source.resolveCancel`). `resolutions` lists the pending ones, `otherResolvers` simulates other plugins' registrations (conflicts). `startPlugin` does not fail activation for such a conflict. Web resolutions are still driven by the test (emit `source.resolveRequest`).
16
+ - Internals: `openStream` is split into prepare / activate / abandon steps shared with file resolvers; the wire client (`sourceResolvers.register`) takes a `{ id, extensions }` declaration and a handler answer with the prepared producer.
17
+
3
18
  ## 1.5.2
4
19
 
5
20
  **Sidebar tabs** (the player after 0.4.0). Additive; `apiVersion` stays `vestra/1`, and plugins written for 1.5.1 need no change. The new members work against older players too (see each one).
@@ -1,10 +1,22 @@
1
- /** Source resolvers turn page URLs into stream URLs for `player.open`. */
2
- import { quiet } from "./runtime.js";
1
+ /**
2
+ * Source resolvers: page URLs to stream URLs, or local files to a stream
3
+ * this plugin generates, for `player.open`.
4
+ */
5
+ import { failure, quiet } from "./runtime.js";
6
+ import { playerOf } from "./player.js";
7
+ import { resolvedStream } from "./stream.js";
8
+ const isFiles = (options) => "extensions" in options;
3
9
  export const resolvers = (scope) => ({
4
10
  add(options) {
5
- const { resolve, ...declaration } = options;
6
11
  const runtime = scope.runtime;
7
- const registration = runtime.host.sourceResolvers.register(declaration, ({ url, signal }) => resolve(url, signal));
12
+ const registration = isFiles(options)
13
+ ? runtime.host.sourceResolvers.register({ id: options.id, extensions: options.extensions }, async ({ url, signal }) => {
14
+ const stream = await options.resolve(url, signal);
15
+ if (stream?.kind !== "stream")
16
+ throw failure("invalid", 'a file resolver returns { kind: "stream", width, height, frame }');
17
+ return resolvedStream(scope, playerOf(runtime), stream, signal);
18
+ })
19
+ : runtime.host.sourceResolvers.register({ id: options.id, schemes: options.schemes, hosts: options.hosts }, ({ url, signal }) => options.resolve(url, signal));
8
20
  let disposed = false;
9
21
  const handle = {
10
22
  ready: quiet(runtime
@@ -1,4 +1,14 @@
1
1
  import type { PlayerService } from "./player.ts";
2
2
  import type { ScopeImpl } from "./scope.ts";
3
- import type { Stream, StreamOptions } from "./types.ts";
3
+ import type { ResolvedProducer } from "../source-resolvers.ts";
4
+ import type { ResolvedStream, Stream, StreamOptions } from "./types.ts";
4
5
  export declare function openStream(scope: ScopeImpl, service: PlayerService, options: StreamOptions): Promise<Stream>;
6
+ /**
7
+ * A file resolver's stream (`ResolvedStream`): prepared and prerolled while
8
+ * the open waits, then handed to the host as the answer. Once the host
9
+ * selected it, the start control is acknowledged and `started` gets the
10
+ * running stream, synchronously, before any `onStop`. Cancelled before or
11
+ * during activation (the open was replaced or closed, the plugin stopped),
12
+ * the producer is released and `started` is never called.
13
+ */
14
+ export declare function resolvedStream(scope: ScopeImpl, service: PlayerService, stream: ResolvedStream, signal: AbortSignal): Promise<ResolvedProducer>;
@@ -154,17 +154,11 @@ async function sendFrame(socket, frame, clock, epoch) {
154
154
  clock.advance(samples.length / 2);
155
155
  }
156
156
  }
157
- export async function openStream(scope, service, options) {
158
- const host = service.runtime.host;
159
- const check = () => {
160
- if (options.signal?.aborted || scope.disposed)
161
- throw abortError();
162
- };
163
- let prepared;
164
- let socket;
165
- let generation;
166
- /** The request selecting the stream, once sent. */
167
- let opened;
157
+ /**
158
+ * Prepare a producer, connect and preroll it until the host could select it.
159
+ * `check` throws to give up (an abort); the producer is then released.
160
+ */
161
+ async function prepare(host, options, check) {
168
162
  const bytes = options.width * options.height * 4;
169
163
  /** The plugin's frame, checked against the declared size. */
170
164
  const frame = () => {
@@ -173,103 +167,183 @@ export async function openStream(scope, service, options) {
173
167
  throw failure("invalid", `frame().video must be ${options.width}×${options.height}×4 = ${bytes} bytes of RGBA`);
174
168
  return next;
175
169
  };
170
+ let producer;
171
+ let socket;
176
172
  try {
177
173
  check();
178
174
  const spec = await host.producer.prepare({
179
175
  width: options.width,
180
176
  height: options.height,
181
177
  });
182
- prepared = spec.producerId;
178
+ producer = spec.producerId;
183
179
  check();
184
180
  socket = await host.producer.connect(spec);
185
181
  check();
186
- let epoch = spec.epoch;
187
- let sample = 0;
188
- let sequence = 0;
189
- const clock = {
190
- get sample() {
191
- return sample;
192
- },
193
- advance: (n) => (sample += n),
194
- nextSequence: () => sequence++,
182
+ const p = {
183
+ producer,
184
+ socket,
185
+ frame,
186
+ epoch: spec.epoch,
187
+ sample: 0,
188
+ sequence: 0,
195
189
  };
190
+ const clock = clockOf(p);
196
191
  // A prepared producer is paused at sample zero. Supply a frame at zero and
197
192
  // enough contiguous PCM before asking the host to select it.
198
- while (sample < PREROLL) {
193
+ while (p.sample < PREROLL) {
199
194
  check();
200
- await writeFrame(socket, frame(), clock, epoch);
195
+ await writeFrame(socket, frame(), clock, p.epoch);
201
196
  }
202
197
  for (const deadline = Date.now() + 10_000;; await sleep(10)) {
203
198
  check();
204
- const state = await host.producer.state(prepared);
199
+ const state = await host.producer.state(producer);
205
200
  if (state.error)
206
201
  throw failure("stream", state.error);
207
- if (state.epoch !== epoch)
202
+ if (state.epoch !== p.epoch)
208
203
  throw failure("stream", "producer epoch changed before activation");
209
204
  if (!["ok", "recoverableXrun", "deviceChanged"].includes(state.health))
210
205
  throw failure("device", `audio device is not ready: ${state.health}`);
211
206
  if (state.acknowledged && state.receivedFrames >= 1024 && state.hasVideo)
212
- break;
207
+ return p;
213
208
  if (Date.now() >= deadline)
214
209
  throw timeout("prefill", "timed out preparing the first buffers");
215
210
  }
211
+ }
212
+ catch (error) {
213
+ socket?.close();
214
+ if (producer !== undefined)
215
+ await host.producer.release(producer).catch(() => { });
216
+ throw error;
217
+ }
218
+ }
219
+ const clockOf = (p) => ({
220
+ get sample() {
221
+ return p.sample;
222
+ },
223
+ advance: (n) => (p.sample += n),
224
+ nextSequence: () => p.sequence++,
225
+ });
226
+ /**
227
+ * Wait until the host runs the selected producer: acknowledge its start
228
+ * control. `selected` says whether the host has accepted the selection yet
229
+ * (`"selected"`: it has, and a producer no longer active was replaced);
230
+ * `check` throws to give up.
231
+ */
232
+ async function activate(host, p, check, selected) {
233
+ for (const deadline = Date.now() + 10_000;; await sleep(10)) {
234
+ check();
235
+ const state = await host.producer.state(p.producer);
236
+ if (state.error)
237
+ throw failure("stream", state.error);
238
+ if (selected === "selected" && !state.active)
239
+ throw abortError();
240
+ if (state.pendingControl) {
241
+ p.epoch = state.pendingControl.epoch;
242
+ p.sample = state.pendingControl.baseSample;
243
+ p.sequence = 0;
244
+ await host.producer.ack(state.pendingControl);
245
+ }
246
+ if ((selected === "selected" || selected()) &&
247
+ state.active &&
248
+ !state.pendingControl &&
249
+ state.acknowledged &&
250
+ state.epoch === p.epoch)
251
+ return;
252
+ if (Date.now() >= deadline)
253
+ throw timeout("activation", "timed out waiting for stream activation");
254
+ }
255
+ }
256
+ /** Give a prepared producer up: its source (if still ours), connection, producer. */
257
+ async function abandon(service, p, generation) {
258
+ // A cancelled open may already have selected this producer: close only
259
+ // that source, before its frame connection (as in `stop`).
260
+ if (generation !== undefined)
261
+ await service.closeIfCurrent(generation).catch(() => { });
262
+ p.socket.close();
263
+ await service.runtime.host.producer.release(p.producer).catch(() => { });
264
+ }
265
+ const handleOf = (scope, service, p, generation) => new StreamHandle(scope, service, {
266
+ producer: p.producer,
267
+ socket: p.socket,
268
+ generation,
269
+ frame: p.frame,
270
+ epoch: p.epoch,
271
+ sample: p.sample,
272
+ sequence: p.sequence,
273
+ });
274
+ export async function openStream(scope, service, options) {
275
+ const host = service.runtime.host;
276
+ const check = () => {
277
+ if (options.signal?.aborted || scope.disposed)
278
+ throw abortError();
279
+ };
280
+ const p = await prepare(host, options, check);
281
+ let generation;
282
+ /** The request selecting the stream. */
283
+ let opened;
284
+ try {
216
285
  let accepted = false;
286
+ let failed;
217
287
  opened = service
218
- .submit({ kind: "openProvider", producerId: prepared, startPaused: false }, { accepted: (ticket) => (generation = ticket.stamp.generation) })
288
+ .submit({ kind: "openProvider", producerId: p.producer, startPaused: false }, { accepted: (ticket) => (generation = ticket.stamp.generation) })
219
289
  .then((ticket) => {
220
290
  generation = ticket.stamp.generation;
221
291
  accepted = true;
222
292
  });
223
- let failed;
224
293
  opened.catch((error) => (failed = error));
225
294
  // Activation needs this side to acknowledge the host's start control.
226
- for (const deadline = Date.now() + 10_000;; await sleep(10)) {
295
+ await activate(host, p, () => {
227
296
  check();
228
297
  if (failed)
229
298
  throw failed;
230
- const state = await host.producer.state(prepared);
231
- if (state.error)
232
- throw failure("stream", state.error);
233
- if (state.pendingControl) {
234
- epoch = state.pendingControl.epoch;
235
- sample = state.pendingControl.baseSample;
236
- sequence = 0;
237
- await host.producer.ack(state.pendingControl);
238
- }
239
- if (accepted &&
240
- state.active &&
241
- !state.pendingControl &&
242
- state.acknowledged &&
243
- state.epoch === epoch)
244
- break;
245
- if (Date.now() >= deadline)
246
- throw timeout("activation", "timed out waiting for stream activation");
247
- }
248
- const handle = new StreamHandle(scope, service, {
249
- producer: prepared,
250
- socket,
251
- generation: generation,
252
- frame,
253
- epoch,
254
- sample,
255
- sequence,
256
- });
257
- prepared = undefined;
258
- socket = undefined;
259
- return handle;
299
+ }, () => accepted);
300
+ return handleOf(scope, service, p, generation);
260
301
  }
261
302
  catch (error) {
262
303
  // An abort can arrive before the host answered the selecting request: wait
263
304
  // for that answer so a source selected afterwards is still closed.
264
- if (opened && prepared !== undefined)
265
- await opened.catch(() => { });
266
- // A cancelled open may already have selected this producer: close only
267
- // that source, before its frame connection (as in `stop`).
268
- if (generation !== undefined && prepared !== undefined)
269
- await service.closeIfCurrent(generation).catch(() => { });
270
- socket?.close();
271
- if (prepared !== undefined)
272
- await host.producer.release(prepared).catch(() => { });
305
+ await opened?.catch(() => { });
306
+ await abandon(service, p, generation);
273
307
  throw error;
274
308
  }
275
309
  }
310
+ /**
311
+ * A file resolver's stream (`ResolvedStream`): prepared and prerolled while
312
+ * the open waits, then handed to the host as the answer. Once the host
313
+ * selected it, the start control is acknowledged and `started` gets the
314
+ * running stream, synchronously, before any `onStop`. Cancelled before or
315
+ * during activation (the open was replaced or closed, the plugin stopped),
316
+ * the producer is released and `started` is never called.
317
+ */
318
+ export async function resolvedStream(scope, service, stream, signal) {
319
+ const host = service.runtime.host;
320
+ const p = await prepare(host, stream, () => {
321
+ if (signal.aborted || scope.disposed)
322
+ throw abortError();
323
+ });
324
+ return {
325
+ kind: "stream",
326
+ producerId: p.producer,
327
+ abandoned: () => abandon(service, p, undefined),
328
+ accepted: async ({ stamp }) => {
329
+ try {
330
+ await activate(host, p, () => {
331
+ if (scope.disposed)
332
+ throw abortError();
333
+ }, "selected");
334
+ // Replaced before it ran: the source is no longer this stream.
335
+ if (service.raw.stamp.generation > stamp.generation)
336
+ throw abortError();
337
+ }
338
+ catch (error) {
339
+ await abandon(service, p, stamp.generation);
340
+ if (error.code !== "aborted")
341
+ scope.report(error);
342
+ return;
343
+ }
344
+ const handle = handleOf(scope, service, p, stamp.generation);
345
+ if (stream.started)
346
+ scope.run(stream.started, handle);
347
+ },
348
+ };
349
+ }
@@ -744,6 +744,45 @@ export declare class TestHost implements Transport {
744
744
  }>;
745
745
  /** Registered source resolvers (`resolvers.add`) by id: their declarations. */
746
746
  readonly resolvers: Map<string, Json>;
747
+ /**
748
+ * Resolvers other plugins hold, as `resolvers.add` declares them plus the
749
+ * holder's id: a registration of this plugin that claims one of their
750
+ * extensions (or scheme + host pairs) is refused with `conflict`, which
751
+ * does not fail activation, like the player.
752
+ *
753
+ * @example
754
+ * ```ts
755
+ * const host = new TestHost();
756
+ * host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
757
+ * ```
758
+ */
759
+ readonly otherResolvers: {
760
+ /** The manifest id of the plugin holding them (shown in the refusal). */
761
+ owner: string;
762
+ /** File extensions it resolves (a file resolver), lower case, no dot. */
763
+ extensions?: string[];
764
+ /** URL schemes it resolves (a web resolver), with `hosts`. */
765
+ schemes?: string[];
766
+ /** Exact host names it resolves (a web resolver), with `schemes`. */
767
+ hosts?: string[];
768
+ }[];
769
+ /**
770
+ * File opens waiting for this plugin's file resolver (`extensions`), by
771
+ * request id: the path and how it was opened. Opening a matching path
772
+ * (`player.open`, {@link TestHost.openElsewhere}) sends
773
+ * `source.resolveRequest`; the answer selects the stream, fails the open
774
+ * or, once the source changed, is `stale`. Any later source change
775
+ * cancels it (`source.resolveCancel`). Web resolvers are not simulated:
776
+ * emit `source.resolveRequest` yourself.
777
+ */
778
+ readonly resolutions: Map<number, {
779
+ /** The id of this plugin's resolver asked (`resolvers.add` `id`). */
780
+ resolverId: string;
781
+ /** The path being opened, as sent to `resolve`. */
782
+ source: string;
783
+ /** The open asked for a paused start (`player.open(path, { paused })`). */
784
+ paused: boolean;
785
+ }>;
747
786
  /**
748
787
  * Answers for `dialogs.open` (paths, `null` = cancelled) and
749
788
  * `dialogs.confirm` (`{ confirmed, checked }`), consumed in order. A
@@ -61,7 +61,7 @@ import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
61
61
  import { createServer } from "node:net";
62
62
  import { randomBytes } from "node:crypto";
63
63
  import { tmpdir } from "node:os";
64
- import { join } from "node:path";
64
+ import { extname, isAbsolute, join } from "node:path";
65
65
  import { fileURLToPath } from "node:url";
66
66
  import { HostError } from "../wire.js";
67
67
  import { COMMAND_LIMITS, MENU_IDS } from "../commands.js";
@@ -110,7 +110,10 @@ const readyCapabilities = {
110
110
  close: true,
111
111
  snapshotSource: true,
112
112
  };
113
- /** Refusals for a position another plugin holds: not activation failures (`startPlugin`). */
113
+ /**
114
+ * Refusals for a position or resolver another plugin holds: not activation
115
+ * failures (`startPlugin`).
116
+ */
114
117
  const takenErrors = new WeakSet();
115
118
  const taken = (message) => {
116
119
  const error = new HostError("conflict", message);
@@ -757,6 +760,29 @@ export class TestHost {
757
760
  producers = new Map();
758
761
  /** Registered source resolvers (`resolvers.add`) by id: their declarations. */
759
762
  resolvers = new Map();
763
+ /**
764
+ * Resolvers other plugins hold, as `resolvers.add` declares them plus the
765
+ * holder's id: a registration of this plugin that claims one of their
766
+ * extensions (or scheme + host pairs) is refused with `conflict`, which
767
+ * does not fail activation, like the player.
768
+ *
769
+ * @example
770
+ * ```ts
771
+ * const host = new TestHost();
772
+ * host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
773
+ * ```
774
+ */
775
+ otherResolvers = [];
776
+ /**
777
+ * File opens waiting for this plugin's file resolver (`extensions`), by
778
+ * request id: the path and how it was opened. Opening a matching path
779
+ * (`player.open`, {@link TestHost.openElsewhere}) sends
780
+ * `source.resolveRequest`; the answer selects the stream, fails the open
781
+ * or, once the source changed, is `stale`. Any later source change
782
+ * cancels it (`source.resolveCancel`). Web resolvers are not simulated:
783
+ * emit `source.resolveRequest` yourself.
784
+ */
785
+ resolutions = new Map();
760
786
  /**
761
787
  * Answers for `dialogs.open` (paths, `null` = cancelled) and
762
788
  * `dialogs.confirm` (`{ confirmed, checked }`), consumed in order. A
@@ -850,8 +876,12 @@ export class TestHost {
850
876
  #overrides = new Map();
851
877
  #listeners = new Map();
852
878
  #inFlight = 0;
853
- #next = { snapshot: 0, job: 0, producer: 0, source: 0 };
879
+ #next = { snapshot: 0, job: 0, producer: 0, source: 0, resolution: 0 };
854
880
  #sourceBook = new Map();
881
+ /** The pending resolutions' stamps and operations (beside `resolutions`). */
882
+ #resolving = new Map();
883
+ /** The producer the source is (`openProvider`, a resolved stream). */
884
+ #selected = null;
855
885
  #scratch = mkdtempSync(join(tmpdir(), "vestra-test-host-"));
856
886
  /**
857
887
  * `plugin.dataDirectory`: a fresh empty temporary directory, removed with
@@ -1351,7 +1381,9 @@ export class TestHost {
1351
1381
  */
1352
1382
  openElsewhere(source, phase = "ready") {
1353
1383
  this.#intent({ kind: "open", source, startPaused: false });
1354
- this.setSource({ phase });
1384
+ // A file this plugin's file resolver claims waits for its answer.
1385
+ if (this.#resolve(source, false) === null)
1386
+ this.setSource({ phase });
1355
1387
  }
1356
1388
  /**
1357
1389
  * Press a binding like the host's table would: panel-focus bindings first
@@ -1682,14 +1714,166 @@ export class TestHost {
1682
1714
  if (control.kind === "rate")
1683
1715
  next.rate = Number(control.rate);
1684
1716
  }
1717
+ if (intent.kind === "openProvider")
1718
+ this.#selected = Number(intent.producerId);
1719
+ if (intent.kind === "open" || intent.kind === "close")
1720
+ this.#selected = null;
1685
1721
  this.source = next;
1686
1722
  this.subtitles.source = {
1687
1723
  generation: next.stamp.generation,
1688
1724
  phase: next.phase,
1689
1725
  };
1690
1726
  this.emit("source.state", this.source);
1727
+ this.#cancelResolutions();
1691
1728
  return { ticket: { stamp: next.stamp, cycle: next.cycle, owner: 1 } };
1692
1729
  }
1730
+ /** The file resolver of this plugin that claims `source`, if any. */
1731
+ #fileResolver(source) {
1732
+ if (source.includes("://") || !isAbsolute(source))
1733
+ return null;
1734
+ const extension = extname(source).slice(1).toLowerCase();
1735
+ if (!extension)
1736
+ return null;
1737
+ for (const [id, declaration] of this.resolvers) {
1738
+ const extensions = declaration.extensions;
1739
+ if (extensions?.includes(extension))
1740
+ return id;
1741
+ }
1742
+ return null;
1743
+ }
1744
+ /** Whether producer `id` is the player's source. */
1745
+ #current(id) {
1746
+ return (this.source.kind === "producer" &&
1747
+ (this.#selected === id || this.source.source === `producer://${id}`));
1748
+ }
1749
+ /** Start resolving `source` for the open just made; its request id, or null. */
1750
+ #resolve(source, paused, operation) {
1751
+ const resolverId = this.#fileResolver(source);
1752
+ if (resolverId === null)
1753
+ return null;
1754
+ const requestId = ++this.#next.resolution;
1755
+ this.resolutions.set(requestId, { resolverId, source, paused });
1756
+ this.#resolving.set(requestId, {
1757
+ stamp: { ...this.source.stamp },
1758
+ ...(operation === undefined ? {} : { operation }),
1759
+ });
1760
+ this.emit("source.resolveRequest", { requestId, resolverId, url: source });
1761
+ return requestId;
1762
+ }
1763
+ /** Settle a resolution's open; `error` fails it. */
1764
+ #settle(requestId, error) {
1765
+ const operation = this.#resolving.get(requestId)?.operation;
1766
+ this.resolutions.delete(requestId);
1767
+ this.#resolving.delete(requestId);
1768
+ if (operation === undefined)
1769
+ return;
1770
+ const entry = this.#sourceBook.get(operation);
1771
+ this.#sourceBook.set(operation, error === undefined
1772
+ ? { status: "completed", ticket: entry?.ticket ?? null }
1773
+ : { status: "failed", error: { backend: error } });
1774
+ }
1775
+ /** Resolutions whose open is no longer the source (or whose resolver left). */
1776
+ #cancelResolutions() {
1777
+ const stamp = this.source.stamp;
1778
+ for (const [requestId, resolution] of [...this.resolutions]) {
1779
+ const pending = this.#resolving.get(requestId);
1780
+ if (pending.stamp.generation === stamp.generation &&
1781
+ pending.stamp.intentVersion === stamp.intentVersion &&
1782
+ this.resolvers.has(resolution.resolverId))
1783
+ continue;
1784
+ this.#settle(requestId, "source resolution cancelled, expired or superseded");
1785
+ this.emit("source.resolveCancel", {
1786
+ requestId,
1787
+ resolverId: resolution.resolverId,
1788
+ });
1789
+ }
1790
+ }
1791
+ /** `sourceResolvers.register`: the player's rules for both forms. */
1792
+ #register(p) {
1793
+ const id = String(p.id);
1794
+ const extensions = p.extensions;
1795
+ if (this.resolvers.has(id))
1796
+ throw new HostError("conflict", "resolver id already registered");
1797
+ if (extensions !== undefined) {
1798
+ if (p.schemes !== undefined ||
1799
+ p.hosts !== undefined ||
1800
+ !Array.isArray(extensions) ||
1801
+ extensions.length < 1 ||
1802
+ extensions.length > 16 ||
1803
+ extensions.some((e) => typeof e !== "string" ||
1804
+ !/^[a-z0-9]{1,16}$/.test(e) ||
1805
+ e === "folder"))
1806
+ throw new HostError("invalid", "file resolvers take 1–16 file extensions (lower case, no dot, not folder)");
1807
+ }
1808
+ const claims = (other) => extensions?.find((e) => other.extensions?.includes(e)) ??
1809
+ (other.hosts?.some((h) => p.hosts?.includes(h)) &&
1810
+ other.schemes?.some((x) => p.schemes?.includes(x))
1811
+ ? "host"
1812
+ : undefined);
1813
+ for (const own of this.resolvers.values())
1814
+ if (claims(own))
1815
+ throw new HostError("conflict", "already registered by this plugin");
1816
+ for (const other of this.otherResolvers) {
1817
+ const what = claims(other);
1818
+ if (what) {
1819
+ const target = what === "host" ? "这个网站" : `文件类型 .${what}`;
1820
+ throw taken(`${target} 已由 ${other.owner} 解析`);
1821
+ }
1822
+ }
1823
+ this.resolvers.set(id, p);
1824
+ }
1825
+ /** `sourceResolvers.respond` for a simulated file resolution. */
1826
+ #respond(p) {
1827
+ const requestId = Number(p.requestId);
1828
+ const resolution = this.resolutions.get(requestId);
1829
+ if (!resolution) {
1830
+ // Web resolutions are not simulated: their answers are only recorded.
1831
+ if (requestId > this.#next.resolution)
1832
+ return null;
1833
+ throw new HostError("stale", "resolution no longer pending");
1834
+ }
1835
+ const fail = (message) => {
1836
+ this.#settle(requestId, message);
1837
+ this.setSource({
1838
+ phase: "error",
1839
+ terminalReason: { failed: message },
1840
+ });
1841
+ throw new HostError("resolution", message);
1842
+ };
1843
+ if (typeof p.error === "string")
1844
+ fail(p.error.trim() ? p.error.slice(0, 2048) : "resolver failed");
1845
+ const result = (p.result ?? {});
1846
+ const producer = this.producers.get(Number(result.producerId));
1847
+ if (result.kind !== "stream")
1848
+ fail("invalid resolved stream descriptor");
1849
+ if (!producer || producer.released)
1850
+ fail("unavailable: producer absent");
1851
+ // Like openProvider, but the source keeps the path and `startPaused`.
1852
+ this.#selected = producer.id;
1853
+ producer.active = true;
1854
+ producer.acknowledged = false;
1855
+ producer.pending = {
1856
+ producerId: producer.id,
1857
+ epoch: producer.epoch + 1,
1858
+ baseSample: producer.receivedFrames,
1859
+ paused: resolution.paused,
1860
+ };
1861
+ this.#settle(requestId);
1862
+ this.setSource({
1863
+ kind: "producer",
1864
+ phase: "ready",
1865
+ paused: resolution.paused,
1866
+ capabilities: {
1867
+ pause: true,
1868
+ seek: false,
1869
+ rate: false,
1870
+ restart: false,
1871
+ close: true,
1872
+ snapshotSource: false,
1873
+ },
1874
+ });
1875
+ return { stamp: { ...this.source.stamp } };
1876
+ }
1693
1877
  #processingState() {
1694
1878
  const list = (stage) => [...this.processing.resources]
1695
1879
  .filter(([, value]) => value.stage === stage)
@@ -2277,8 +2461,17 @@ export class TestHost {
2277
2461
  "source.state": () => this.source,
2278
2462
  "source.submit": (p) => {
2279
2463
  const intent = p.intent;
2280
- const result = { status: "completed", ...this.#intent(intent) };
2281
- this.#sourceBook.set(Number(p.operationId), result);
2464
+ const ticket = this.#intent(intent);
2465
+ const operation = Number(p.operationId);
2466
+ // A file this plugin's file resolver claims: pending until it answers.
2467
+ if (intent.kind === "open" &&
2468
+ this.#resolve(String(intent.source), intent.startPaused === true, operation) !== null) {
2469
+ const pending = { status: "pending", ...ticket };
2470
+ this.#sourceBook.set(operation, pending);
2471
+ return pending;
2472
+ }
2473
+ const result = { status: "completed", ...ticket };
2474
+ this.#sourceBook.set(operation, result);
2282
2475
  // Selecting a provider asks it to acknowledge a start control.
2283
2476
  const producer = this.producers.get(Number(intent.producerId));
2284
2477
  if (intent.kind === "openProvider" && producer) {
@@ -2706,9 +2899,12 @@ export class TestHost {
2706
2899
  return { jobId: job.jobId, cancel: "requested" };
2707
2900
  },
2708
2901
  "jobs.operation": () => ({ status: "unknown" }),
2709
- "sourceResolvers.register": (p) => void this.resolvers.set(String(p.id), p),
2710
- "sourceResolvers.unregister": (p) => void this.resolvers.delete(String(p.id)),
2711
- "sourceResolvers.respond": () => null,
2902
+ "sourceResolvers.register": (p) => void this.#register(p),
2903
+ "sourceResolvers.unregister": (p) => {
2904
+ this.resolvers.delete(String(p.id));
2905
+ this.#cancelResolutions();
2906
+ },
2907
+ "sourceResolvers.respond": (p) => this.#respond(p),
2712
2908
  "producer.prepare": async () => {
2713
2909
  const id = ++this.#next.producer;
2714
2910
  const tcp = this.producerTransport === "tcp";
@@ -2754,8 +2950,7 @@ export class TestHost {
2754
2950
  });
2755
2951
  socket.on("error", () => { });
2756
2952
  socket.on("close", () => {
2757
- if (this.source.kind === "producer" &&
2758
- this.source.source === `producer://${producer.id}`)
2953
+ if (this.#current(producer.id))
2759
2954
  producer.droppedWhileSelected = true;
2760
2955
  });
2761
2956
  });
@@ -2786,8 +2981,7 @@ export class TestHost {
2786
2981
  const producer = this.producers.get(Number(p.producerId));
2787
2982
  if (!producer)
2788
2983
  throw new HostError("missing", "producer");
2789
- const current = this.source.kind === "producer" &&
2790
- this.source.source === `producer://${producer.id}`;
2984
+ const current = this.#current(producer.id);
2791
2985
  return {
2792
2986
  input: null,
2793
2987
  pendingControl: producer.pending,
@@ -2889,7 +3083,8 @@ export async function startPlugin(activate, options = {}) {
2889
3083
  mkdirSync(data, { recursive: true });
2890
3084
  const instance = await createPlugin(host, manifest, directory, data, host.app);
2891
3085
  await activate(instance.plugin);
2892
- // Like the player: a position another plugin holds is not an activation failure.
3086
+ // Like the player: a position or resolver another plugin holds is not an
3087
+ // activation failure.
2893
3088
  for (;;) {
2894
3089
  try {
2895
3090
  await instance.activated();
@@ -2771,7 +2771,12 @@ export interface PlayerState {
2771
2771
  readonly kind: "media" | "stream";
2772
2772
  /** The source's life cycle. */
2773
2773
  readonly phase: "empty" | "loading" | "ready" | "ended" | "error";
2774
- /** Path or URL. */
2774
+ /**
2775
+ * Path or URL; `null` when empty. A generated stream started with
2776
+ * {@link Player.openStream} has the opaque `producer://<n>` (do not parse
2777
+ * it); one a file resolver generates ({@link FileResolverOptions}) keeps
2778
+ * the path that was opened. Tell streams apart by `kind`, not by `source`.
2779
+ */
2775
2780
  readonly source: string | null;
2776
2781
  /** Playback is paused. */
2777
2782
  readonly paused: boolean;
@@ -3087,7 +3092,9 @@ export interface Player {
3087
3092
  *
3088
3093
  * It does not wait for playback: use {@link Media.ready}. `paused` opens
3089
3094
  * it paused; aborting `signal` closes it. URLs matching a registered
3090
- * {@link Resolver} are resolved first.
3095
+ * {@link Resolver} are resolved first, and so are local files of an
3096
+ * extension a file resolver claims (the source is then a stream that
3097
+ * plugin generates; `paused` reaches it).
3091
3098
  */
3092
3099
  open(source: string, options?: {
3093
3100
  paused?: boolean;
@@ -3811,8 +3818,13 @@ export interface MediaCapture {
3811
3818
  */
3812
3819
  capture(options?: CaptureOptions): Promise<Snapshot>;
3813
3820
  }
3814
- /** Declaration of {@link Resolvers.add}. */
3815
- export interface ResolverOptions {
3821
+ /**
3822
+ * Declaration of {@link Resolvers.add}: a web resolver
3823
+ * ({@link WebResolverOptions}) or a file resolver ({@link FileResolverOptions}).
3824
+ */
3825
+ export type ResolverOptions = WebResolverOptions | FileResolverOptions;
3826
+ /** A resolver for web pages: `https://host/…` to playable stream URLs. */
3827
+ export interface WebResolverOptions {
3816
3828
  /** Local id, unique within the plugin. */
3817
3829
  id: string;
3818
3830
  /** URL schemes handled. */
@@ -3822,14 +3834,104 @@ export interface ResolverOptions {
3822
3834
  /** Turn a page URL into playable stream URLs. */
3823
3835
  resolve(url: string, signal: AbortSignal): Promise<ResolvedMedia>;
3824
3836
  }
3837
+ /**
3838
+ * A resolver for local files by extension (SDK 1.5.3, player 0.4.1): when
3839
+ * anyone opens such a file with {@link Player.open} (the Open dialog, the
3840
+ * playlist, next / previous, the recent list, a drop that calls `open`),
3841
+ * the player asks this plugin for a stream it generates instead of
3842
+ * playing the file. Needs `source.resolve` and `producer.av`.
3843
+ *
3844
+ * @remarks
3845
+ * The open keeps everything of the original call: its `ifCurrent`, its
3846
+ * operation (the opener's `open` and `media.ready()` settle as for any
3847
+ * file), `paused` (the stream starts paused), and its path as
3848
+ * `state.source`, with `state.kind` `"stream"`. A thrown error fails that
3849
+ * open: `phase` `"error"` with the error's message as `state.error`,
3850
+ * word for word. The stream belongs to this plugin, as with
3851
+ * {@link Player.openStream}.
3852
+ *
3853
+ * Cancellation: `signal` aborts when the open is replaced, closed
3854
+ * (`player.close()`, F4) or times out (30 seconds), or this resolver is
3855
+ * disposed. Cancelled after `resolve` returned but before the stream ran,
3856
+ * the SDK releases the stream and never calls `started`.
3857
+ *
3858
+ * One extension has one resolver: another plugin's resolver for it makes
3859
+ * {@link Resolver.ready} reject with `conflict` without failing activation
3860
+ * (the player shows a warning). When this plugin stops, its extensions are
3861
+ * free again and such files go to the player as before.
3862
+ *
3863
+ * @example
3864
+ * ```ts
3865
+ * const { readFile } = await import("node:fs/promises");
3866
+ * // The plugin's own emulator: one frame per call.
3867
+ * const emulator = (rom: Uint8Array): (() => StreamFrame) => {
3868
+ * const video = new Uint8Array(256 * 240 * 4).fill(rom[0] ?? 0);
3869
+ * return () => ({ video, audio: new Float32Array(1600) });
3870
+ * };
3871
+ * plugin.resolvers.add({
3872
+ * id: "nes",
3873
+ * extensions: ["nes"],
3874
+ * async resolve(path, signal) {
3875
+ * const frame = emulator(await readFile(path, { signal }));
3876
+ * return {
3877
+ * kind: "stream",
3878
+ * width: 256,
3879
+ * height: 240,
3880
+ * frame,
3881
+ * started: (stream) => stream.onStop((error) => error && plugin.osd.show(error.message)),
3882
+ * };
3883
+ * },
3884
+ * });
3885
+ * ```
3886
+ */
3887
+ export interface FileResolverOptions {
3888
+ /** Local id, unique within the plugin. */
3889
+ id: string;
3890
+ /**
3891
+ * Lower case, without the dot: 1–16 entries of 1–16 lower-case letters or
3892
+ * digits (not `"folder"`). Only absolute local paths match, by their
3893
+ * extension in any case (`/roms/Game.NES`); URLs never do.
3894
+ */
3895
+ extensions: string[];
3896
+ /** Read the file and describe the stream to generate for it. */
3897
+ resolve(path: string, signal: AbortSignal): Promise<ResolvedStream>;
3898
+ }
3899
+ /**
3900
+ * What a file resolver returns: a stream, with the same frame contract as
3901
+ * {@link StreamOptions} (size limits, `frame()` on the audio clock).
3902
+ */
3903
+ export interface ResolvedStream {
3904
+ /** Always `"stream"`. */
3905
+ kind: "stream";
3906
+ /** Frame width in pixels. */
3907
+ width: number;
3908
+ /** Frame height in pixels. */
3909
+ height: number;
3910
+ /** Produce the next frame. Never called concurrently or while paused. */
3911
+ frame(): StreamFrame;
3912
+ /**
3913
+ * Called with the running stream once the player plays it, synchronously
3914
+ * after activation and before any {@link Stream.onStop}. Not called when
3915
+ * the open was cancelled first. `stream.stop()` closes the source (if
3916
+ * still current); the plugin stopping stops it too.
3917
+ */
3918
+ started?(stream: Stream): void;
3919
+ }
3825
3920
  /** A registered resolver. Disposing it unregisters it and aborts resolutions in flight. */
3826
3921
  export interface Resolver extends Disposable {
3827
- /** Resolves when the player registered it; rejects (e.g. `conflict`) otherwise. */
3922
+ /**
3923
+ * Resolves when the player registered it; rejects otherwise: `conflict`
3924
+ * when another resolver has the extension or the scheme + host (another
3925
+ * plugin's: activation goes on; this plugin's own: activation fails),
3926
+ * `invalid` for a malformed declaration (a player before 0.4.1 also for
3927
+ * `extensions`).
3928
+ */
3828
3929
  readonly ready: Promise<void>;
3829
3930
  }
3830
3931
  /**
3831
- * `plugin.resolvers`: turn web page URLs into playable streams. Needs
3832
- * `source.resolve`.
3932
+ * `plugin.resolvers`: turn web page URLs into playable streams, or local
3933
+ * files into streams this plugin generates. Needs `source.resolve` (file
3934
+ * resolvers also `producer.av`).
3833
3935
  *
3834
3936
  * @example
3835
3937
  * ```ts
@@ -3846,9 +3948,11 @@ export interface Resolver extends Disposable {
3846
3948
  */
3847
3949
  export interface Resolvers {
3848
3950
  /**
3849
- * Register a resolver. When anyone opens a matching URL the player calls
3850
- * `resolve`; `signal` aborts when the open is cancelled or replaced. One
3851
- * scheme + host pair has one resolver (`conflict` otherwise).
3951
+ * Register a resolver. When anyone opens a matching URL or file the
3952
+ * player calls `resolve`; `signal` aborts when the open is cancelled or
3953
+ * replaced. One scheme + host pair, and one extension, has one resolver
3954
+ * (`conflict` otherwise). A rejected `resolve` fails the open with the
3955
+ * error's message.
3852
3956
  */
3853
3957
  add(options: ResolverOptions): Resolver;
3854
3958
  }
@@ -4029,10 +4133,18 @@ export interface PluginInfo {
4029
4133
  * @since 1.5.2
4030
4134
  */
4031
4135
  export interface PluginWarning {
4032
- /** `"slot-occupied"` or `"sequence-taken"`; a newer player may add codes. */
4136
+ /**
4137
+ * `"slot-occupied"`, `"sequence-taken"` or `"resolver-taken"` (player
4138
+ * 0.4.1); a newer player may add codes.
4139
+ */
4033
4140
  code: string;
4034
4141
  /** The panel position (`"bottom"`, `"sidebar"`, `"backdrop"`) for `slot-occupied`. */
4035
4142
  slot?: string;
4143
+ /**
4144
+ * What was taken, for `resolver-taken`: 「文件类型 .nes」 or the
4145
+ * `scheme://host` of a web resolver.
4146
+ */
4147
+ target?: string;
4036
4148
  /** The manifest id of the plugin holding it (absent for a full sidebar). */
4037
4149
  owner?: string;
4038
4150
  }
@@ -8,7 +8,7 @@ export type ResolvedResource = {
8
8
  headers?: Record<string, string>;
9
9
  };
10
10
  /**
11
- * What a resolver returns ({@link ResolverOptions.resolve}): the playable
11
+ * What a resolver returns ({@link WebResolverOptions.resolve}): the playable
12
12
  * stream(s) behind a page URL. Both streams must use identical `headers`;
13
13
  * different maps fail explicitly (nothing is dropped silently).
14
14
  *
@@ -35,21 +35,48 @@ export type ResolvedMedia = {
35
35
  /** A separate audio stream, when audio and video are split. */
36
36
  audio?: ResolvedResource;
37
37
  };
38
+ /**
39
+ * A resolver declaration on the wire: a web resolver (`schemes` + `hosts`)
40
+ * or, from player 0.4.1, a file resolver (`extensions`), never both.
41
+ */
38
42
  export type SourceResolverDeclaration = {
39
43
  id: string;
40
44
  /** Finite initial contract: http and/or https, exact normalized host names. */
41
45
  schemes: ("http" | "https")[];
42
46
  hosts: string[];
47
+ } | {
48
+ id: string;
49
+ /** Local files by extension: 1–16, lower-case letters or digits, no dot. */
50
+ extensions: string[];
51
+ };
52
+ /**
53
+ * A file resolver's answer as the handler gives it: the producer it prepared
54
+ * (sent to the host), `accepted` once the host selected it (with the stamp of
55
+ * the source it became) and `abandoned` when it never will be: the request
56
+ * was cancelled or answered too late. Exactly one of the two is called.
57
+ */
58
+ export type ResolvedProducer = {
59
+ kind: "stream";
60
+ producerId: number;
61
+ accepted(reply: {
62
+ stamp: {
63
+ generation: number;
64
+ intentVersion: number;
65
+ };
66
+ }): Promise<void>;
67
+ abandoned(): Promise<void>;
43
68
  };
44
69
  export declare function sourceResolverClient(transport: Transport): {
45
- /** Requires source.resolve. One registered host/scheme pair has one owner.
46
- * Resolution shares source.submit arbitration and the original operation ID.
47
- * This backend supports paired streams with identical header maps; different
48
- * maps fail explicitly. No header is silently discarded or made global. */
70
+ /** Requires source.resolve. One registered host/scheme pair, and one file
71
+ * extension, has one owner (another plugin: `conflict`, reason
72
+ * `resolver-taken`). Resolution shares source.submit arbitration and the
73
+ * original operation ID. This backend supports paired streams with
74
+ * identical header maps; different maps fail explicitly. No header is
75
+ * silently discarded or made global. A failure is sent as its message. */
49
76
  register(declaration: SourceResolverDeclaration, handler: (request: {
50
77
  url: string;
51
78
  signal: AbortSignal;
52
- }) => Promise<ResolvedMedia>): Promise<{
79
+ }) => Promise<ResolvedMedia | ResolvedProducer>): Promise<{
53
80
  dispose(): Promise<void>;
54
81
  }>;
55
82
  };
@@ -1,9 +1,14 @@
1
+ const isProducer = (result) => result?.kind === "stream";
2
+ /** The words of a failure, as the user will read them. */
3
+ const reason = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 2048);
1
4
  export function sourceResolverClient(transport) {
2
5
  return {
3
- /** Requires source.resolve. One registered host/scheme pair has one owner.
4
- * Resolution shares source.submit arbitration and the original operation ID.
5
- * This backend supports paired streams with identical header maps; different
6
- * maps fail explicitly. No header is silently discarded or made global. */
6
+ /** Requires source.resolve. One registered host/scheme pair, and one file
7
+ * extension, has one owner (another plugin: `conflict`, reason
8
+ * `resolver-taken`). Resolution shares source.submit arbitration and the
9
+ * original operation ID. This backend supports paired streams with
10
+ * identical header maps; different maps fail explicitly. No header is
11
+ * silently discarded or made global. A failure is sent as its message. */
7
12
  async register(declaration, handler) {
8
13
  const active = new Map();
9
14
  let disposed = false;
@@ -13,33 +18,56 @@ export function sourceResolverClient(transport) {
13
18
  return;
14
19
  const controller = new AbortController();
15
20
  active.set(request.requestId, controller);
21
+ const live = () => !controller.signal.aborted && !disposed;
16
22
  void (async () => {
23
+ let result;
17
24
  try {
18
- const result = await handler({
25
+ result = await handler({
19
26
  url: request.url,
20
27
  signal: controller.signal,
21
28
  });
22
- if (!controller.signal.aborted && !disposed) {
23
- await transport.request("sourceResolvers.respond", {
24
- requestId: request.requestId,
25
- result,
26
- });
27
- }
28
29
  }
29
30
  catch (error) {
30
- if (!controller.signal.aborted && !disposed) {
31
+ if (live())
31
32
  await transport
32
33
  .request("sourceResolvers.respond", {
33
34
  requestId: request.requestId,
34
- error: String(error).slice(0, 2048),
35
+ error: reason(error),
35
36
  })
36
37
  .catch(() => { });
38
+ active.delete(request.requestId);
39
+ return;
40
+ }
41
+ try {
42
+ if (!isProducer(result)) {
43
+ if (live())
44
+ await transport.request("sourceResolvers.respond", {
45
+ requestId: request.requestId,
46
+ result,
47
+ });
48
+ return;
49
+ }
50
+ if (!live())
51
+ return void (await result.abandoned());
52
+ let reply;
53
+ try {
54
+ // Answered: the host no longer cancels this request.
55
+ active.delete(request.requestId);
56
+ reply = await transport.request("sourceResolvers.respond", {
57
+ requestId: request.requestId,
58
+ result: { kind: "stream", producerId: result.producerId },
59
+ });
60
+ }
61
+ catch (error) {
62
+ await result.abandoned();
63
+ throw error;
37
64
  }
65
+ await result.accepted(reply);
38
66
  }
39
67
  finally {
40
68
  active.delete(request.requestId);
41
69
  }
42
- })();
70
+ })().catch(() => { });
43
71
  });
44
72
  const offCancel = transport.on("source.resolveCancel", (value) => {
45
73
  const request = value;
package/dist/wire.d.ts CHANGED
@@ -593,7 +593,7 @@ export declare function createWire(transport: Transport): {
593
593
  register(declaration: import("./source-resolvers.ts").SourceResolverDeclaration, handler: (request: {
594
594
  url: string;
595
595
  signal: AbortSignal;
596
- }) => Promise<import("./source-resolvers.ts").ResolvedMedia>): Promise<{
596
+ }) => Promise<import("./source-resolvers.ts").ResolvedMedia | import("./source-resolvers.ts").ResolvedProducer>): Promise<{
597
597
  dispose(): Promise<void>;
598
598
  }>;
599
599
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vestra-player/sdk",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "Plugin API types, test host and plugin CLI for Vestra Player (apiVersion vestra/1)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {