sonovault 1.0.0 → 1.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/README.md +32 -15
- package/dist/index.cjs +117 -7
- package/dist/index.d.cts +83 -4
- package/dist/index.d.ts +83 -4
- package/dist/index.js +114 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
[](https://github.com/rekordcloud/sonovault-js/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/sonovault)
|
|
5
5
|
|
|
6
|
-
TypeScript/Node client for the **[SonoVault](https://sonovault.now)** music metadata API
|
|
6
|
+
TypeScript/Node client for the **[SonoVault](https://sonovault.now)** music metadata API. 90M+ tracks with ISRC, ISWC, genre, record label, canonical release dates, and cross-platform IDs for Spotify, Apple Music, Tidal, Beatport, Discogs, and MusicBrainz. One call resolves them all.
|
|
7
7
|
|
|
8
|
-
- **One key, no OAuth
|
|
9
|
-
- **Free tier
|
|
10
|
-
- **Docs
|
|
8
|
+
- **One key, no OAuth.** A single `x-api-key` header, no approval queue.
|
|
9
|
+
- **Free tier.** 1,000 requests/month, no credit card: [get an API key](https://sonovault.now).
|
|
10
|
+
- **Docs.** Full API reference at [sonovault.now/docs](https://sonovault.now/docs).
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
@@ -17,6 +17,8 @@ npm install sonovault
|
|
|
17
17
|
|
|
18
18
|
Node 18+ (uses the built-in `fetch`). ESM and CommonJS both supported.
|
|
19
19
|
|
|
20
|
+
Use this library server-side. Shipping your API key in browser code exposes it to anyone who opens devtools. If a key leaks, rotate it in your [dashboard](https://sonovault.now/dashboard/keys).
|
|
21
|
+
|
|
20
22
|
## Quickstart
|
|
21
23
|
|
|
22
24
|
```ts
|
|
@@ -32,16 +34,16 @@ console.log(results[0].genre, results[0].releases[0]?.label?.name);
|
|
|
32
34
|
// Resolve that ISRC to its ID on every platform
|
|
33
35
|
const { links } = await sv.tracks.links({ isrc: "GBDUW0000053" });
|
|
34
36
|
for (const link of links) {
|
|
35
|
-
console.log(link.source, link.url); // spotify https://open.spotify.com/track
|
|
37
|
+
console.log(link.source, link.url); // spotify https://open.spotify.com/track/...
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
// Recording
|
|
40
|
+
// Recording to composition (ISWC), for royalty and publishing workflows
|
|
39
41
|
const work = await sv.tracks.iswc({ isrc: "GBDUW0000053" });
|
|
40
42
|
```
|
|
41
43
|
|
|
42
44
|
## Bulk resolve
|
|
43
45
|
|
|
44
|
-
Resolve up to 100 lines
|
|
46
|
+
Resolve up to 100 lines in one request: track names, ISRCs, or platform IDs. Useful for enriching play logs and library exports.
|
|
45
47
|
|
|
46
48
|
```ts
|
|
47
49
|
const batch = await sv.tracks.resolve({
|
|
@@ -59,13 +61,23 @@ for (const row of batch.results) {
|
|
|
59
61
|
|
|
60
62
|
## Pagination
|
|
61
63
|
|
|
62
|
-
List endpoints return `{ results, next_cursor }
|
|
64
|
+
List endpoints return `{ results, next_cursor }`. Pass the cursor back to get the next page. `next_cursor` is `null` on the last page.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { paginate } from "sonovault";
|
|
68
|
+
|
|
69
|
+
for await (const release of paginate((cursor) => sv.artists.releases(42, { cursor }))) {
|
|
70
|
+
console.log(release.title);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or walk the cursor yourself:
|
|
63
75
|
|
|
64
76
|
```ts
|
|
65
77
|
let cursor: string | undefined;
|
|
66
78
|
do {
|
|
67
79
|
const page = await sv.artists.releases(42, { cursor });
|
|
68
|
-
//
|
|
80
|
+
// ...use page.results
|
|
69
81
|
cursor = page.next_cursor ?? undefined;
|
|
70
82
|
} while (cursor);
|
|
71
83
|
```
|
|
@@ -86,7 +98,12 @@ try {
|
|
|
86
98
|
}
|
|
87
99
|
```
|
|
88
100
|
|
|
89
|
-
Rate-limited responses that carry a `Retry-After` header are retried automatically
|
|
101
|
+
Rate-limited responses that carry a `Retry-After` header are retried automatically. The default is 2 retries, configurable with `maxRetries`.
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
## Examples
|
|
105
|
+
|
|
106
|
+
Runnable scripts live in [`examples/`](examples/): find an ISRC, resolve cross-platform links, enrich a play log, follow live stream events over SSE, and verify webhook deliveries.
|
|
90
107
|
|
|
91
108
|
## API coverage
|
|
92
109
|
|
|
@@ -99,15 +116,15 @@ Rate-limited responses that carry a `Retry-After` header are retried automatical
|
|
|
99
116
|
| `sv.genres` | `list` |
|
|
100
117
|
| `sv.suggestions` | `submit`, `list` |
|
|
101
118
|
| `sv.streams` | `create`, `list`, `get`, `update`, `history`, `report`, `live`, `stop` |
|
|
102
|
-
| `sv.webhooks` | `create`, `list`, `update`, `delete`, `test`, `deliveries` |
|
|
119
|
+
| `sv.webhooks` | `create`, `list`, `get`, `update`, `delete`, `test`, `deliveries` |
|
|
103
120
|
|
|
104
|
-
Some endpoints (audio identify, browse,
|
|
121
|
+
Some endpoints (audio identify, browse, stream monitoring) need a paid tier. See [pricing](https://sonovault.now/pricing). Everything else works on the free tier.
|
|
105
122
|
|
|
106
123
|
## Related
|
|
107
124
|
|
|
108
|
-
- [SonoVault API docs](https://sonovault.now/docs)
|
|
109
|
-
- [sonovault-python](https://github.com/rekordcloud/sonovault-python)
|
|
110
|
-
- [Free ISRC lookup](https://sonovault.now/isrc-lookup)
|
|
125
|
+
- [SonoVault API docs](https://sonovault.now/docs). Full endpoint reference with examples in 8 languages.
|
|
126
|
+
- [sonovault-python](https://github.com/rekordcloud/sonovault-python). The Python client.
|
|
127
|
+
- [Free ISRC lookup](https://sonovault.now/isrc-lookup) and [ISWC lookup](https://sonovault.now/iswc-lookup). Browser tools built on the same API.
|
|
111
128
|
|
|
112
129
|
## License
|
|
113
130
|
|
package/dist/index.cjs
CHANGED
|
@@ -21,7 +21,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
SonoVault: () => SonoVault,
|
|
24
|
-
SonoVaultError: () => SonoVaultError
|
|
24
|
+
SonoVaultError: () => SonoVaultError,
|
|
25
|
+
paginate: () => paginate,
|
|
26
|
+
verifyWebhookSignature: () => verifyWebhookSignature
|
|
25
27
|
});
|
|
26
28
|
module.exports = __toCommonJS(index_exports);
|
|
27
29
|
|
|
@@ -47,6 +49,9 @@ var SonoVaultError = class extends Error {
|
|
|
47
49
|
}
|
|
48
50
|
};
|
|
49
51
|
|
|
52
|
+
// src/version.ts
|
|
53
|
+
var VERSION = "1.2.0";
|
|
54
|
+
|
|
50
55
|
// src/client.ts
|
|
51
56
|
var SonoVault = class {
|
|
52
57
|
constructor(options) {
|
|
@@ -120,8 +125,17 @@ var SonoVault = class {
|
|
|
120
125
|
update: (id, body) => this.request(`/v1/streams/${id}`, { method: "PATCH", json: body }),
|
|
121
126
|
history: (id, params = {}) => this.request(`/v1/streams/${id}/history`, { query: params }),
|
|
122
127
|
report: (params) => this.request("/v1/streams/report", { query: params }),
|
|
123
|
-
/**
|
|
124
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Real-time play events for your monitored streams, as an async iterator
|
|
130
|
+
* over Server-Sent Events. Runs until you `break` or abort the signal.
|
|
131
|
+
*
|
|
132
|
+
* ```ts
|
|
133
|
+
* for await (const event of sv.streams.live()) {
|
|
134
|
+
* console.log(event.data.track?.title);
|
|
135
|
+
* }
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
live: (options = {}) => this.sse("/v1/streams/live", options.signal),
|
|
125
139
|
/** Stop monitoring a stream. */
|
|
126
140
|
stop: (id) => this.request(`/v1/streams/${id}`, { method: "DELETE" })
|
|
127
141
|
};
|
|
@@ -129,6 +143,7 @@ var SonoVault = class {
|
|
|
129
143
|
/** Register an endpoint for stream events. The response includes `secret` once — store it. */
|
|
130
144
|
create: (body) => this.request("/v1/webhooks", { method: "POST", json: body }),
|
|
131
145
|
list: () => this.request("/v1/webhooks"),
|
|
146
|
+
get: (id) => this.request(`/v1/webhooks/${id}`),
|
|
132
147
|
update: (id, body) => this.request(`/v1/webhooks/${id}`, { method: "PATCH", json: body }),
|
|
133
148
|
delete: (id) => this.request(`/v1/webhooks/${id}`, { method: "DELETE" }),
|
|
134
149
|
test: (id) => this.request(`/v1/webhooks/${id}/test`, { method: "POST" }),
|
|
@@ -138,6 +153,7 @@ var SonoVault = class {
|
|
|
138
153
|
this.apiKey = options.apiKey;
|
|
139
154
|
this.baseUrl = (options.baseUrl ?? "https://api.sonovault.now").replace(/\/$/, "");
|
|
140
155
|
this.maxRetries = options.maxRetries ?? 2;
|
|
156
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
141
157
|
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
142
158
|
}
|
|
143
159
|
async request(path, opts = {}) {
|
|
@@ -145,7 +161,10 @@ var SonoVault = class {
|
|
|
145
161
|
for (const [key, value] of Object.entries(opts.query ?? {})) {
|
|
146
162
|
if (value !== void 0) url.searchParams.set(key, String(value));
|
|
147
163
|
}
|
|
148
|
-
const headers = {
|
|
164
|
+
const headers = {
|
|
165
|
+
"x-api-key": this.apiKey,
|
|
166
|
+
"User-Agent": `sonovault-js/${VERSION}`
|
|
167
|
+
};
|
|
149
168
|
let body;
|
|
150
169
|
if (opts.json !== void 0) {
|
|
151
170
|
headers["Content-Type"] = "application/json";
|
|
@@ -158,9 +177,15 @@ var SonoVault = class {
|
|
|
158
177
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
159
178
|
let res;
|
|
160
179
|
try {
|
|
161
|
-
|
|
180
|
+
const signal = this.timeoutMs > 0 ? AbortSignal.timeout(this.timeoutMs) : void 0;
|
|
181
|
+
res = await this.fetchImpl(url, { method: opts.method ?? "GET", headers, body, signal });
|
|
162
182
|
} catch (err) {
|
|
163
|
-
|
|
183
|
+
const e = err;
|
|
184
|
+
const timedOut = e.name === "TimeoutError" || e.name === "AbortError";
|
|
185
|
+
lastError = new SonoVaultError(
|
|
186
|
+
timedOut ? `Request timed out after ${this.timeoutMs}ms` : `Network error: ${e.message}`,
|
|
187
|
+
0
|
|
188
|
+
);
|
|
164
189
|
continue;
|
|
165
190
|
}
|
|
166
191
|
if (res.ok) {
|
|
@@ -178,9 +203,94 @@ var SonoVault = class {
|
|
|
178
203
|
}
|
|
179
204
|
throw lastError ?? new SonoVaultError("Request failed", 0);
|
|
180
205
|
}
|
|
206
|
+
/** Connect to an SSE endpoint and yield one parsed JSON event per `data:` frame. */
|
|
207
|
+
async *sse(path, signal) {
|
|
208
|
+
const url = new URL(this.baseUrl + path);
|
|
209
|
+
const res = await this.fetchImpl(url, {
|
|
210
|
+
headers: {
|
|
211
|
+
"x-api-key": this.apiKey,
|
|
212
|
+
"User-Agent": `sonovault-js/${VERSION}`,
|
|
213
|
+
Accept: "text/event-stream"
|
|
214
|
+
},
|
|
215
|
+
signal
|
|
216
|
+
});
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
const body = await res.json().catch(() => void 0);
|
|
219
|
+
const message = body?.error ?? `HTTP ${res.status}`;
|
|
220
|
+
throw new SonoVaultError(message, res.status, body);
|
|
221
|
+
}
|
|
222
|
+
if (!res.body) throw new SonoVaultError("SSE response has no body", res.status);
|
|
223
|
+
const reader = res.body.getReader();
|
|
224
|
+
const decoder = new TextDecoder();
|
|
225
|
+
let buffer = "";
|
|
226
|
+
let dataLines = [];
|
|
227
|
+
try {
|
|
228
|
+
while (true) {
|
|
229
|
+
const { done, value } = await reader.read();
|
|
230
|
+
if (done) break;
|
|
231
|
+
buffer += decoder.decode(value, { stream: true });
|
|
232
|
+
let newline;
|
|
233
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
234
|
+
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
|
235
|
+
buffer = buffer.slice(newline + 1);
|
|
236
|
+
if (line === "") {
|
|
237
|
+
if (dataLines.length > 0) {
|
|
238
|
+
const data = dataLines.join("\n");
|
|
239
|
+
dataLines = [];
|
|
240
|
+
try {
|
|
241
|
+
yield JSON.parse(data);
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} else if (line.startsWith("data:")) {
|
|
246
|
+
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} finally {
|
|
251
|
+
reader.releaseLock();
|
|
252
|
+
res.body.cancel().catch(() => {
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
181
256
|
};
|
|
257
|
+
|
|
258
|
+
// src/webhooks.ts
|
|
259
|
+
var import_node_crypto = require("crypto");
|
|
260
|
+
function verifyWebhookSignature(options) {
|
|
261
|
+
const { secret, header, payload, toleranceSeconds = 300 } = options;
|
|
262
|
+
if (!secret || !header) return false;
|
|
263
|
+
const parts = {};
|
|
264
|
+
for (const kv of header.split(",")) {
|
|
265
|
+
const i = kv.indexOf("=");
|
|
266
|
+
if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
|
|
267
|
+
}
|
|
268
|
+
const t = Number(parts.t);
|
|
269
|
+
const v1 = parts.v1;
|
|
270
|
+
if (!Number.isFinite(t) || !v1) return false;
|
|
271
|
+
if (toleranceSeconds > 0 && Math.abs(Math.floor(Date.now() / 1e3) - t) > toleranceSeconds) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
const body = typeof payload === "string" ? payload : payload.toString("utf8");
|
|
275
|
+
const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(`${t}.${body}`).digest("hex");
|
|
276
|
+
const a = Buffer.from(v1);
|
|
277
|
+
const b = Buffer.from(expected);
|
|
278
|
+
return a.length === b.length && (0, import_node_crypto.timingSafeEqual)(a, b);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/pagination.ts
|
|
282
|
+
async function* paginate(fetchPage) {
|
|
283
|
+
let cursor;
|
|
284
|
+
do {
|
|
285
|
+
const page = await fetchPage(cursor);
|
|
286
|
+
for (const item of page.results) yield item;
|
|
287
|
+
cursor = page.next_cursor ?? void 0;
|
|
288
|
+
} while (cursor);
|
|
289
|
+
}
|
|
182
290
|
// Annotate the CommonJS export names for ESM import in node:
|
|
183
291
|
0 && (module.exports = {
|
|
184
292
|
SonoVault,
|
|
185
|
-
SonoVaultError
|
|
293
|
+
SonoVaultError,
|
|
294
|
+
paginate,
|
|
295
|
+
verifyWebhookSignature
|
|
186
296
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -152,10 +152,28 @@ interface Webhook {
|
|
|
152
152
|
id: string;
|
|
153
153
|
url: string;
|
|
154
154
|
event_types?: string[];
|
|
155
|
-
/** Returned once, on creation
|
|
155
|
+
/** Returned once, on creation. Store it to verify delivery signatures. */
|
|
156
156
|
secret?: string;
|
|
157
157
|
[key: string]: unknown;
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* An event from the live SSE feed or a webhook delivery.
|
|
161
|
+
* Types: `stream.play.started`, `stream.offline`, `stream.online`.
|
|
162
|
+
*/
|
|
163
|
+
interface StreamEvent {
|
|
164
|
+
id: string;
|
|
165
|
+
type: string;
|
|
166
|
+
created: number | string;
|
|
167
|
+
data: {
|
|
168
|
+
stream_id: string;
|
|
169
|
+
started_at?: string;
|
|
170
|
+
at?: string;
|
|
171
|
+
reason?: string;
|
|
172
|
+
track?: Track;
|
|
173
|
+
[key: string]: unknown;
|
|
174
|
+
};
|
|
175
|
+
[key: string]: unknown;
|
|
176
|
+
}
|
|
159
177
|
|
|
160
178
|
interface SonoVaultOptions {
|
|
161
179
|
/** Your API key — get a free one at https://sonovault.now (1,000 requests/month). */
|
|
@@ -164,6 +182,11 @@ interface SonoVaultOptions {
|
|
|
164
182
|
baseUrl?: string;
|
|
165
183
|
/** Retries on 429/5xx responses. Default 2; set 0 to disable. */
|
|
166
184
|
maxRetries?: number;
|
|
185
|
+
/**
|
|
186
|
+
* Per-request timeout in milliseconds. Default 30000; set 0 to disable.
|
|
187
|
+
* Does not apply to `streams.live()`, which stays open indefinitely.
|
|
188
|
+
*/
|
|
189
|
+
timeoutMs?: number;
|
|
167
190
|
/** Custom fetch implementation (for testing or polyfills). */
|
|
168
191
|
fetch?: typeof globalThis.fetch;
|
|
169
192
|
}
|
|
@@ -171,9 +194,12 @@ declare class SonoVault {
|
|
|
171
194
|
private readonly apiKey;
|
|
172
195
|
private readonly baseUrl;
|
|
173
196
|
private readonly maxRetries;
|
|
197
|
+
private readonly timeoutMs;
|
|
174
198
|
private readonly fetchImpl;
|
|
175
199
|
constructor(options: SonoVaultOptions);
|
|
176
200
|
private request;
|
|
201
|
+
/** Connect to an SSE endpoint and yield one parsed JSON event per `data:` frame. */
|
|
202
|
+
private sse;
|
|
177
203
|
readonly tracks: {
|
|
178
204
|
/** Search by artist + title (both required — there is no free-text query). */
|
|
179
205
|
search: (params: {
|
|
@@ -307,8 +333,19 @@ declare class SonoVault {
|
|
|
307
333
|
until: string;
|
|
308
334
|
stream_id?: string;
|
|
309
335
|
}) => Promise<Record<string, unknown>>;
|
|
310
|
-
/**
|
|
311
|
-
|
|
336
|
+
/**
|
|
337
|
+
* Real-time play events for your monitored streams, as an async iterator
|
|
338
|
+
* over Server-Sent Events. Runs until you `break` or abort the signal.
|
|
339
|
+
*
|
|
340
|
+
* ```ts
|
|
341
|
+
* for await (const event of sv.streams.live()) {
|
|
342
|
+
* console.log(event.data.track?.title);
|
|
343
|
+
* }
|
|
344
|
+
* ```
|
|
345
|
+
*/
|
|
346
|
+
live: (options?: {
|
|
347
|
+
signal?: AbortSignal;
|
|
348
|
+
}) => AsyncGenerator<StreamEvent, any, any>;
|
|
312
349
|
/** Stop monitoring a stream. */
|
|
313
350
|
stop: (id: string) => Promise<void>;
|
|
314
351
|
};
|
|
@@ -322,6 +359,7 @@ declare class SonoVault {
|
|
|
322
359
|
list: () => Promise<{
|
|
323
360
|
webhooks: Webhook[];
|
|
324
361
|
}>;
|
|
362
|
+
get: (id: string) => Promise<Webhook>;
|
|
325
363
|
update: (id: string, body: Record<string, unknown>) => Promise<Webhook>;
|
|
326
364
|
delete: (id: string) => Promise<void>;
|
|
327
365
|
test: (id: string) => Promise<Record<string, unknown>>;
|
|
@@ -344,4 +382,45 @@ declare class SonoVaultError extends Error {
|
|
|
344
382
|
get isRateLimited(): boolean;
|
|
345
383
|
}
|
|
346
384
|
|
|
347
|
-
|
|
385
|
+
/**
|
|
386
|
+
* Verify a `SonoVault-Signature` webhook header against the raw request body.
|
|
387
|
+
*
|
|
388
|
+
* The header format is `t=<unix>,v1=<hex>` where
|
|
389
|
+
* `v1 = HMAC-SHA256(secret, "<t>.<rawBody>")`. Use the `secret` returned once
|
|
390
|
+
* by `sv.webhooks.create()`. Compute over the RAW body bytes, before any JSON
|
|
391
|
+
* parsing. Constant-time compare. Rejects timestamps outside
|
|
392
|
+
* `toleranceSeconds` (default 300; pass 0 to disable the age check).
|
|
393
|
+
*
|
|
394
|
+
* ```ts
|
|
395
|
+
* app.post("/webhooks/sonovault", express.raw({ type: "application/json" }), (req, res) => {
|
|
396
|
+
* const ok = verifyWebhookSignature({
|
|
397
|
+
* secret: process.env.SONOVAULT_WEBHOOK_SECRET!,
|
|
398
|
+
* header: req.header("SonoVault-Signature") ?? "",
|
|
399
|
+
* payload: req.body,
|
|
400
|
+
* });
|
|
401
|
+
* if (!ok) return res.status(400).end();
|
|
402
|
+
* res.status(200).end();
|
|
403
|
+
* });
|
|
404
|
+
* ```
|
|
405
|
+
*/
|
|
406
|
+
declare function verifyWebhookSignature(options: {
|
|
407
|
+
secret: string;
|
|
408
|
+
header: string;
|
|
409
|
+
payload: string | Buffer;
|
|
410
|
+
toleranceSeconds?: number;
|
|
411
|
+
}): boolean;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Iterate every item across all pages of a cursor-paginated endpoint.
|
|
415
|
+
*
|
|
416
|
+
* ```ts
|
|
417
|
+
* import { paginate } from "sonovault";
|
|
418
|
+
*
|
|
419
|
+
* for await (const release of paginate((cursor) => sv.artists.releases(42, { cursor }))) {
|
|
420
|
+
* console.log(release.title);
|
|
421
|
+
* }
|
|
422
|
+
* ```
|
|
423
|
+
*/
|
|
424
|
+
declare function paginate<T>(fetchPage: (cursor: string | undefined) => Promise<Page<T>>): AsyncGenerator<T>;
|
|
425
|
+
|
|
426
|
+
export { type Artist, type Genre, type IdentifyRequest, type IdentifyResponse, type IdentifyResult, type IswcLookupResponse, type Label, type Page, type PlatformLink, type PlatformLinksResponse, type Release, type ResolveInputType, type ResolveRequest, type ResolveResponse, type ResolveResult, SonoVault, SonoVaultError, type SonoVaultOptions, type Stream, type StreamEvent, type Track, type TrackArtist, type TrackRelease, type Webhook, paginate, verifyWebhookSignature };
|
package/dist/index.d.ts
CHANGED
|
@@ -152,10 +152,28 @@ interface Webhook {
|
|
|
152
152
|
id: string;
|
|
153
153
|
url: string;
|
|
154
154
|
event_types?: string[];
|
|
155
|
-
/** Returned once, on creation
|
|
155
|
+
/** Returned once, on creation. Store it to verify delivery signatures. */
|
|
156
156
|
secret?: string;
|
|
157
157
|
[key: string]: unknown;
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* An event from the live SSE feed or a webhook delivery.
|
|
161
|
+
* Types: `stream.play.started`, `stream.offline`, `stream.online`.
|
|
162
|
+
*/
|
|
163
|
+
interface StreamEvent {
|
|
164
|
+
id: string;
|
|
165
|
+
type: string;
|
|
166
|
+
created: number | string;
|
|
167
|
+
data: {
|
|
168
|
+
stream_id: string;
|
|
169
|
+
started_at?: string;
|
|
170
|
+
at?: string;
|
|
171
|
+
reason?: string;
|
|
172
|
+
track?: Track;
|
|
173
|
+
[key: string]: unknown;
|
|
174
|
+
};
|
|
175
|
+
[key: string]: unknown;
|
|
176
|
+
}
|
|
159
177
|
|
|
160
178
|
interface SonoVaultOptions {
|
|
161
179
|
/** Your API key — get a free one at https://sonovault.now (1,000 requests/month). */
|
|
@@ -164,6 +182,11 @@ interface SonoVaultOptions {
|
|
|
164
182
|
baseUrl?: string;
|
|
165
183
|
/** Retries on 429/5xx responses. Default 2; set 0 to disable. */
|
|
166
184
|
maxRetries?: number;
|
|
185
|
+
/**
|
|
186
|
+
* Per-request timeout in milliseconds. Default 30000; set 0 to disable.
|
|
187
|
+
* Does not apply to `streams.live()`, which stays open indefinitely.
|
|
188
|
+
*/
|
|
189
|
+
timeoutMs?: number;
|
|
167
190
|
/** Custom fetch implementation (for testing or polyfills). */
|
|
168
191
|
fetch?: typeof globalThis.fetch;
|
|
169
192
|
}
|
|
@@ -171,9 +194,12 @@ declare class SonoVault {
|
|
|
171
194
|
private readonly apiKey;
|
|
172
195
|
private readonly baseUrl;
|
|
173
196
|
private readonly maxRetries;
|
|
197
|
+
private readonly timeoutMs;
|
|
174
198
|
private readonly fetchImpl;
|
|
175
199
|
constructor(options: SonoVaultOptions);
|
|
176
200
|
private request;
|
|
201
|
+
/** Connect to an SSE endpoint and yield one parsed JSON event per `data:` frame. */
|
|
202
|
+
private sse;
|
|
177
203
|
readonly tracks: {
|
|
178
204
|
/** Search by artist + title (both required — there is no free-text query). */
|
|
179
205
|
search: (params: {
|
|
@@ -307,8 +333,19 @@ declare class SonoVault {
|
|
|
307
333
|
until: string;
|
|
308
334
|
stream_id?: string;
|
|
309
335
|
}) => Promise<Record<string, unknown>>;
|
|
310
|
-
/**
|
|
311
|
-
|
|
336
|
+
/**
|
|
337
|
+
* Real-time play events for your monitored streams, as an async iterator
|
|
338
|
+
* over Server-Sent Events. Runs until you `break` or abort the signal.
|
|
339
|
+
*
|
|
340
|
+
* ```ts
|
|
341
|
+
* for await (const event of sv.streams.live()) {
|
|
342
|
+
* console.log(event.data.track?.title);
|
|
343
|
+
* }
|
|
344
|
+
* ```
|
|
345
|
+
*/
|
|
346
|
+
live: (options?: {
|
|
347
|
+
signal?: AbortSignal;
|
|
348
|
+
}) => AsyncGenerator<StreamEvent, any, any>;
|
|
312
349
|
/** Stop monitoring a stream. */
|
|
313
350
|
stop: (id: string) => Promise<void>;
|
|
314
351
|
};
|
|
@@ -322,6 +359,7 @@ declare class SonoVault {
|
|
|
322
359
|
list: () => Promise<{
|
|
323
360
|
webhooks: Webhook[];
|
|
324
361
|
}>;
|
|
362
|
+
get: (id: string) => Promise<Webhook>;
|
|
325
363
|
update: (id: string, body: Record<string, unknown>) => Promise<Webhook>;
|
|
326
364
|
delete: (id: string) => Promise<void>;
|
|
327
365
|
test: (id: string) => Promise<Record<string, unknown>>;
|
|
@@ -344,4 +382,45 @@ declare class SonoVaultError extends Error {
|
|
|
344
382
|
get isRateLimited(): boolean;
|
|
345
383
|
}
|
|
346
384
|
|
|
347
|
-
|
|
385
|
+
/**
|
|
386
|
+
* Verify a `SonoVault-Signature` webhook header against the raw request body.
|
|
387
|
+
*
|
|
388
|
+
* The header format is `t=<unix>,v1=<hex>` where
|
|
389
|
+
* `v1 = HMAC-SHA256(secret, "<t>.<rawBody>")`. Use the `secret` returned once
|
|
390
|
+
* by `sv.webhooks.create()`. Compute over the RAW body bytes, before any JSON
|
|
391
|
+
* parsing. Constant-time compare. Rejects timestamps outside
|
|
392
|
+
* `toleranceSeconds` (default 300; pass 0 to disable the age check).
|
|
393
|
+
*
|
|
394
|
+
* ```ts
|
|
395
|
+
* app.post("/webhooks/sonovault", express.raw({ type: "application/json" }), (req, res) => {
|
|
396
|
+
* const ok = verifyWebhookSignature({
|
|
397
|
+
* secret: process.env.SONOVAULT_WEBHOOK_SECRET!,
|
|
398
|
+
* header: req.header("SonoVault-Signature") ?? "",
|
|
399
|
+
* payload: req.body,
|
|
400
|
+
* });
|
|
401
|
+
* if (!ok) return res.status(400).end();
|
|
402
|
+
* res.status(200).end();
|
|
403
|
+
* });
|
|
404
|
+
* ```
|
|
405
|
+
*/
|
|
406
|
+
declare function verifyWebhookSignature(options: {
|
|
407
|
+
secret: string;
|
|
408
|
+
header: string;
|
|
409
|
+
payload: string | Buffer;
|
|
410
|
+
toleranceSeconds?: number;
|
|
411
|
+
}): boolean;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Iterate every item across all pages of a cursor-paginated endpoint.
|
|
415
|
+
*
|
|
416
|
+
* ```ts
|
|
417
|
+
* import { paginate } from "sonovault";
|
|
418
|
+
*
|
|
419
|
+
* for await (const release of paginate((cursor) => sv.artists.releases(42, { cursor }))) {
|
|
420
|
+
* console.log(release.title);
|
|
421
|
+
* }
|
|
422
|
+
* ```
|
|
423
|
+
*/
|
|
424
|
+
declare function paginate<T>(fetchPage: (cursor: string | undefined) => Promise<Page<T>>): AsyncGenerator<T>;
|
|
425
|
+
|
|
426
|
+
export { type Artist, type Genre, type IdentifyRequest, type IdentifyResponse, type IdentifyResult, type IswcLookupResponse, type Label, type Page, type PlatformLink, type PlatformLinksResponse, type Release, type ResolveInputType, type ResolveRequest, type ResolveResponse, type ResolveResult, SonoVault, SonoVaultError, type SonoVaultOptions, type Stream, type StreamEvent, type Track, type TrackArtist, type TrackRelease, type Webhook, paginate, verifyWebhookSignature };
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,9 @@ var SonoVaultError = class extends Error {
|
|
|
20
20
|
}
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
// src/version.ts
|
|
24
|
+
var VERSION = "1.2.0";
|
|
25
|
+
|
|
23
26
|
// src/client.ts
|
|
24
27
|
var SonoVault = class {
|
|
25
28
|
constructor(options) {
|
|
@@ -93,8 +96,17 @@ var SonoVault = class {
|
|
|
93
96
|
update: (id, body) => this.request(`/v1/streams/${id}`, { method: "PATCH", json: body }),
|
|
94
97
|
history: (id, params = {}) => this.request(`/v1/streams/${id}/history`, { query: params }),
|
|
95
98
|
report: (params) => this.request("/v1/streams/report", { query: params }),
|
|
96
|
-
/**
|
|
97
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Real-time play events for your monitored streams, as an async iterator
|
|
101
|
+
* over Server-Sent Events. Runs until you `break` or abort the signal.
|
|
102
|
+
*
|
|
103
|
+
* ```ts
|
|
104
|
+
* for await (const event of sv.streams.live()) {
|
|
105
|
+
* console.log(event.data.track?.title);
|
|
106
|
+
* }
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
live: (options = {}) => this.sse("/v1/streams/live", options.signal),
|
|
98
110
|
/** Stop monitoring a stream. */
|
|
99
111
|
stop: (id) => this.request(`/v1/streams/${id}`, { method: "DELETE" })
|
|
100
112
|
};
|
|
@@ -102,6 +114,7 @@ var SonoVault = class {
|
|
|
102
114
|
/** Register an endpoint for stream events. The response includes `secret` once — store it. */
|
|
103
115
|
create: (body) => this.request("/v1/webhooks", { method: "POST", json: body }),
|
|
104
116
|
list: () => this.request("/v1/webhooks"),
|
|
117
|
+
get: (id) => this.request(`/v1/webhooks/${id}`),
|
|
105
118
|
update: (id, body) => this.request(`/v1/webhooks/${id}`, { method: "PATCH", json: body }),
|
|
106
119
|
delete: (id) => this.request(`/v1/webhooks/${id}`, { method: "DELETE" }),
|
|
107
120
|
test: (id) => this.request(`/v1/webhooks/${id}/test`, { method: "POST" }),
|
|
@@ -111,6 +124,7 @@ var SonoVault = class {
|
|
|
111
124
|
this.apiKey = options.apiKey;
|
|
112
125
|
this.baseUrl = (options.baseUrl ?? "https://api.sonovault.now").replace(/\/$/, "");
|
|
113
126
|
this.maxRetries = options.maxRetries ?? 2;
|
|
127
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
114
128
|
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
115
129
|
}
|
|
116
130
|
async request(path, opts = {}) {
|
|
@@ -118,7 +132,10 @@ var SonoVault = class {
|
|
|
118
132
|
for (const [key, value] of Object.entries(opts.query ?? {})) {
|
|
119
133
|
if (value !== void 0) url.searchParams.set(key, String(value));
|
|
120
134
|
}
|
|
121
|
-
const headers = {
|
|
135
|
+
const headers = {
|
|
136
|
+
"x-api-key": this.apiKey,
|
|
137
|
+
"User-Agent": `sonovault-js/${VERSION}`
|
|
138
|
+
};
|
|
122
139
|
let body;
|
|
123
140
|
if (opts.json !== void 0) {
|
|
124
141
|
headers["Content-Type"] = "application/json";
|
|
@@ -131,9 +148,15 @@ var SonoVault = class {
|
|
|
131
148
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
132
149
|
let res;
|
|
133
150
|
try {
|
|
134
|
-
|
|
151
|
+
const signal = this.timeoutMs > 0 ? AbortSignal.timeout(this.timeoutMs) : void 0;
|
|
152
|
+
res = await this.fetchImpl(url, { method: opts.method ?? "GET", headers, body, signal });
|
|
135
153
|
} catch (err) {
|
|
136
|
-
|
|
154
|
+
const e = err;
|
|
155
|
+
const timedOut = e.name === "TimeoutError" || e.name === "AbortError";
|
|
156
|
+
lastError = new SonoVaultError(
|
|
157
|
+
timedOut ? `Request timed out after ${this.timeoutMs}ms` : `Network error: ${e.message}`,
|
|
158
|
+
0
|
|
159
|
+
);
|
|
137
160
|
continue;
|
|
138
161
|
}
|
|
139
162
|
if (res.ok) {
|
|
@@ -151,8 +174,93 @@ var SonoVault = class {
|
|
|
151
174
|
}
|
|
152
175
|
throw lastError ?? new SonoVaultError("Request failed", 0);
|
|
153
176
|
}
|
|
177
|
+
/** Connect to an SSE endpoint and yield one parsed JSON event per `data:` frame. */
|
|
178
|
+
async *sse(path, signal) {
|
|
179
|
+
const url = new URL(this.baseUrl + path);
|
|
180
|
+
const res = await this.fetchImpl(url, {
|
|
181
|
+
headers: {
|
|
182
|
+
"x-api-key": this.apiKey,
|
|
183
|
+
"User-Agent": `sonovault-js/${VERSION}`,
|
|
184
|
+
Accept: "text/event-stream"
|
|
185
|
+
},
|
|
186
|
+
signal
|
|
187
|
+
});
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const body = await res.json().catch(() => void 0);
|
|
190
|
+
const message = body?.error ?? `HTTP ${res.status}`;
|
|
191
|
+
throw new SonoVaultError(message, res.status, body);
|
|
192
|
+
}
|
|
193
|
+
if (!res.body) throw new SonoVaultError("SSE response has no body", res.status);
|
|
194
|
+
const reader = res.body.getReader();
|
|
195
|
+
const decoder = new TextDecoder();
|
|
196
|
+
let buffer = "";
|
|
197
|
+
let dataLines = [];
|
|
198
|
+
try {
|
|
199
|
+
while (true) {
|
|
200
|
+
const { done, value } = await reader.read();
|
|
201
|
+
if (done) break;
|
|
202
|
+
buffer += decoder.decode(value, { stream: true });
|
|
203
|
+
let newline;
|
|
204
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
205
|
+
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
|
206
|
+
buffer = buffer.slice(newline + 1);
|
|
207
|
+
if (line === "") {
|
|
208
|
+
if (dataLines.length > 0) {
|
|
209
|
+
const data = dataLines.join("\n");
|
|
210
|
+
dataLines = [];
|
|
211
|
+
try {
|
|
212
|
+
yield JSON.parse(data);
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} else if (line.startsWith("data:")) {
|
|
217
|
+
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} finally {
|
|
222
|
+
reader.releaseLock();
|
|
223
|
+
res.body.cancel().catch(() => {
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
154
227
|
};
|
|
228
|
+
|
|
229
|
+
// src/webhooks.ts
|
|
230
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
231
|
+
function verifyWebhookSignature(options) {
|
|
232
|
+
const { secret, header, payload, toleranceSeconds = 300 } = options;
|
|
233
|
+
if (!secret || !header) return false;
|
|
234
|
+
const parts = {};
|
|
235
|
+
for (const kv of header.split(",")) {
|
|
236
|
+
const i = kv.indexOf("=");
|
|
237
|
+
if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
|
|
238
|
+
}
|
|
239
|
+
const t = Number(parts.t);
|
|
240
|
+
const v1 = parts.v1;
|
|
241
|
+
if (!Number.isFinite(t) || !v1) return false;
|
|
242
|
+
if (toleranceSeconds > 0 && Math.abs(Math.floor(Date.now() / 1e3) - t) > toleranceSeconds) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
const body = typeof payload === "string" ? payload : payload.toString("utf8");
|
|
246
|
+
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
|
|
247
|
+
const a = Buffer.from(v1);
|
|
248
|
+
const b = Buffer.from(expected);
|
|
249
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/pagination.ts
|
|
253
|
+
async function* paginate(fetchPage) {
|
|
254
|
+
let cursor;
|
|
255
|
+
do {
|
|
256
|
+
const page = await fetchPage(cursor);
|
|
257
|
+
for (const item of page.results) yield item;
|
|
258
|
+
cursor = page.next_cursor ?? void 0;
|
|
259
|
+
} while (cursor);
|
|
260
|
+
}
|
|
155
261
|
export {
|
|
156
262
|
SonoVault,
|
|
157
|
-
SonoVaultError
|
|
263
|
+
SonoVaultError,
|
|
264
|
+
paginate,
|
|
265
|
+
verifyWebhookSignature
|
|
158
266
|
};
|
package/package.json
CHANGED