@rivi-software/radiotools-sdk 0.0.0-stage → 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/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@rivi-software/radiotools-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Customer SDK for radio stations. Published listener reads and playback ingest only.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "sideEffects": false,
14
+ "scripts": {
15
+ "check": "tsc --noEmit",
16
+ "test": "bun test"
17
+ },
18
+ "dependencies": {
19
+ "zod": "4.6.5"
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "1.2.23",
23
+ "typescript": "5.9.3"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/RIVI-Software/radiotools-sdk.git",
31
+ "directory": "packages/sdk-typescript"
32
+ }
33
+ }
package/src/client.ts ADDED
@@ -0,0 +1,587 @@
1
+ import {
2
+ contentQuery,
3
+ contentRecordQuery,
4
+ currentBroadcastQuery,
5
+ graphqlRequest,
6
+ isGraphqlUnavailable,
7
+ nowPlayingQuery,
8
+ parseGraphqlContent,
9
+ parseGraphqlCurrentBroadcast,
10
+ parseGraphqlNowPlaying,
11
+ parseGraphqlPrograms,
12
+ parseGraphqlSchedule,
13
+ parseGraphqlSchemas,
14
+ parseGraphqlStation,
15
+ parseGraphqlTrackHistory,
16
+ parseGraphqlTraffic,
17
+ parseGraphqlWeather,
18
+ programsQuery,
19
+ scheduleQuery,
20
+ schemasQuery,
21
+ stationQuery,
22
+ trackHistoryQuery,
23
+ trafficQuery,
24
+ weatherQuery,
25
+ } from "./graphql";
26
+ import type { EtagCache, FetchLike, PublicJsonRequestOptions } from "./http";
27
+ import { readPublicJson } from "./http";
28
+ import { reportPlayback } from "./playback";
29
+ import { previewEditorial } from "./preview";
30
+ import type { PublicSocketHandlers, PublicSocketSubscription } from "./realtime";
31
+ import { type SubscribePublicSocketOptions, subscribePublicSocket } from "./realtime";
32
+ import type {
33
+ SdkPlaybackObservation,
34
+ SdkPlaybackObservationResult,
35
+ SdkPreviewInput,
36
+ SdkPublicCurrentBroadcast,
37
+ SdkPublicNowPlaying,
38
+ SdkPublicPrograms,
39
+ SdkPublicSchedule,
40
+ SdkPublicSchemaList,
41
+ SdkPublicStation,
42
+ SdkPublicTrackHistory,
43
+ SdkPublishedCollection,
44
+ SdkPublishedRecord,
45
+ SdkRevisionPreview,
46
+ SdkTrafficPublic,
47
+ SdkWeatherPublic,
48
+ } from "./schemas";
49
+ import { sdkSchemas } from "./schemas";
50
+
51
+ export type StationClientTransport = "http" | "graphql" | "auto";
52
+
53
+ export type CreateStationClientOptions = {
54
+ baseUrl: string;
55
+ stationSlug: string;
56
+ /** Credential with `listener:read`. Required for published reads. */
57
+ apiToken?: string;
58
+ /** Integration credential named `playback` with `ingest:write`. Required for `reportPlayback`. */
59
+ ingestToken?: string;
60
+ createSocket?: SubscribePublicSocketOptions["createSocket"];
61
+ fetch?: FetchLike;
62
+ etagCache?: EtagCache;
63
+ /**
64
+ * `http` — REST reads with ETag conditional GET (default).
65
+ * `graphql` — POST /api/v1/stations/{slug}/graphql.
66
+ * `auto` — GraphQL, falling back to HTTP when that route is missing.
67
+ */
68
+ transport?: StationClientTransport;
69
+ /** @deprecated Use `transport: "auto"`. */
70
+ graphql?: boolean;
71
+ /** Extra attempts after HTTP 429 or 503. Defaults to 2. */
72
+ retries?: number;
73
+ };
74
+
75
+ export type ContentListOptions = {
76
+ type: string;
77
+ cursor?: string;
78
+ limit?: number;
79
+ filter?: string[];
80
+ };
81
+
82
+ export type ScheduleWindow = {
83
+ fromDate: string;
84
+ throughDate: string;
85
+ };
86
+
87
+ export type TrackHistoryOptions = {
88
+ cursor?: string;
89
+ limit?: number;
90
+ };
91
+
92
+ export type ReportPlaybackTarget = {
93
+ stationId?: string;
94
+ };
95
+
96
+ export type StationClient = {
97
+ getStation: () => Promise<SdkPublicStation>;
98
+ getSchemas: () => Promise<SdkPublicSchemaList>;
99
+ getContent: (options: ContentListOptions) => Promise<SdkPublishedCollection>;
100
+ getContentRecord: (id: string) => Promise<SdkPublishedRecord>;
101
+ iterateContent: (
102
+ options: Omit<ContentListOptions, "cursor">,
103
+ ) => AsyncGenerator<SdkPublishedRecord>;
104
+ getSchedule: (window: ScheduleWindow) => Promise<SdkPublicSchedule>;
105
+ getPrograms: (window: ScheduleWindow) => Promise<SdkPublicPrograms>;
106
+ getCurrentBroadcast: () => Promise<SdkPublicCurrentBroadcast>;
107
+ getNowPlaying: () => Promise<SdkPublicNowPlaying>;
108
+ getTrackHistory: (options?: TrackHistoryOptions) => Promise<SdkPublicTrackHistory>;
109
+ iterateTrackHistory: (
110
+ options?: Omit<TrackHistoryOptions, "cursor">,
111
+ ) => AsyncGenerator<SdkPublicTrackHistory["items"][number]>;
112
+ getWeather: () => Promise<SdkWeatherPublic | null>;
113
+ getTraffic: () => Promise<SdkTrafficPublic | null>;
114
+ /** Milliseconds from `station.delivery.pollIntervalSeconds`. */
115
+ recommendedPollIntervalMs: () => Promise<number>;
116
+ previewEditorial: (grant: string, input: SdkPreviewInput) => Promise<SdkRevisionPreview>;
117
+ reportPlayback: (
118
+ observation: SdkPlaybackObservation,
119
+ target?: ReportPlaybackTarget,
120
+ ) => Promise<SdkPlaybackObservationResult>;
121
+ subscribe: (
122
+ handlers: PublicSocketHandlers,
123
+ window?: Partial<ScheduleWindow>,
124
+ ) => PublicSocketSubscription;
125
+ };
126
+
127
+ function absoluteUrl(baseUrl: string, path: string): string {
128
+ return new URL(path, baseUrl).href;
129
+ }
130
+
131
+ function requirePublicHref(href: string | null, name: string): string {
132
+ if (!href) throw new Error(`Public ${name} API is unavailable for this station`);
133
+ return href;
134
+ }
135
+
136
+ function isRealDate(value: string): boolean {
137
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
138
+ const [year, month, day] = value.split("-").map(Number);
139
+ if (year === undefined || month === undefined || day === undefined) return false;
140
+ const date = new Date(Date.UTC(year, month - 1, day));
141
+ return (
142
+ date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
143
+ );
144
+ }
145
+
146
+ function assertScheduleWindow(window: ScheduleWindow): void {
147
+ if (!isRealDate(window.fromDate) || !isRealDate(window.throughDate)) {
148
+ throw new Error("Schedule dates must be YYYY-MM-DD");
149
+ }
150
+ const from = Date.parse(`${window.fromDate}T00:00:00.000Z`);
151
+ const through = Date.parse(`${window.throughDate}T00:00:00.000Z`);
152
+ const inclusiveDays = Math.round((through - from) / 86_400_000) + 1;
153
+ if (inclusiveDays < 1 || inclusiveDays > 31) {
154
+ throw new Error("Schedule window must cover 1 to 31 inclusive days");
155
+ }
156
+ }
157
+
158
+ function useGraphql(options: CreateStationClientOptions): boolean {
159
+ if (options.transport === "http") return false;
160
+ if (options.transport === "graphql") return true;
161
+ return options.graphql === true || options.transport === "auto";
162
+ }
163
+
164
+ export function createStationClient(options: CreateStationClientOptions): StationClient {
165
+ const listenerToken = options.apiToken?.trim() ?? "";
166
+ const ingestToken = options.ingestToken?.trim() ?? "";
167
+ if (!listenerToken && !ingestToken) {
168
+ throw new Error("A listener API token or playback ingest token is required");
169
+ }
170
+ if (!options.stationSlug.trim()) throw new Error("stationSlug is required");
171
+
172
+ const stationPath = `/api/v1/stations/${encodeURIComponent(options.stationSlug)}`;
173
+ const httpOptions: PublicJsonRequestOptions = {
174
+ fetch: options.fetch,
175
+ apiToken: listenerToken || undefined,
176
+ etagCache: options.etagCache,
177
+ cache: "no-store",
178
+ credentials: "omit",
179
+ retries: options.retries,
180
+ };
181
+ const graphqlEndpoint = absoluteUrl(options.baseUrl, `${stationPath}/graphql`);
182
+ let stationCache: SdkPublicStation | null = null;
183
+
184
+ const requireListenerToken = () => {
185
+ if (!listenerToken) throw new Error("Listener API token (listener:read) is required");
186
+ };
187
+
188
+ async function preferGraphql<T>(graphql: () => Promise<T>, http: () => Promise<T>): Promise<T> {
189
+ if (!useGraphql(options)) return http();
190
+ try {
191
+ return await graphql();
192
+ } catch (error) {
193
+ if (options.transport !== "auto" || !isGraphqlUnavailable(error)) throw error;
194
+ return http();
195
+ }
196
+ }
197
+
198
+ const loadStationHttp = async () => {
199
+ const { data } = await readPublicJson(
200
+ absoluteUrl(options.baseUrl, stationPath),
201
+ sdkSchemas.PublicStation,
202
+ httpOptions,
203
+ );
204
+ stationCache = data;
205
+ return data;
206
+ };
207
+
208
+ const getStation = async (): Promise<SdkPublicStation> => {
209
+ requireListenerToken();
210
+ return preferGraphql(async () => {
211
+ const data = await graphqlRequest<{ station: unknown }>(
212
+ graphqlEndpoint,
213
+ stationQuery,
214
+ undefined,
215
+ httpOptions,
216
+ );
217
+ stationCache = parseGraphqlStation(data);
218
+ return stationCache;
219
+ }, loadStationHttp);
220
+ };
221
+
222
+ const stationFor = async (resource: keyof SdkPublicStation["resources"], name: string) => {
223
+ const station = stationCache ?? (await getStation());
224
+ return { station, href: requirePublicHref(station.resources[resource], name) };
225
+ };
226
+
227
+ const optionalStationResource = async (resource: "weather" | "traffic") => {
228
+ const station = stationCache ?? (await getStation());
229
+ return station.resources[resource] ? station : null;
230
+ };
231
+
232
+ const getSchemas = async (): Promise<SdkPublicSchemaList> => {
233
+ const { href } = await stationFor("schemas", "schemas");
234
+ return preferGraphql(async () => {
235
+ const data = await graphqlRequest<{ schemas: unknown[] }>(
236
+ graphqlEndpoint,
237
+ schemasQuery,
238
+ undefined,
239
+ httpOptions,
240
+ );
241
+ return parseGraphqlSchemas(data);
242
+ }, async () => {
243
+ const { data } = await readPublicJson(
244
+ absoluteUrl(options.baseUrl, href),
245
+ sdkSchemas.PublicSchemaList,
246
+ httpOptions,
247
+ );
248
+ return data;
249
+ });
250
+ };
251
+
252
+ const getContent = async (list: ContentListOptions): Promise<SdkPublishedCollection> => {
253
+ const { href } = await stationFor("content", "content");
254
+ const limit = list.limit ?? 25;
255
+ const readHttp = async () => {
256
+ const params = new URLSearchParams({ type: list.type, limit: String(limit) });
257
+ if (list.cursor) params.set("cursor", list.cursor);
258
+ for (const filter of list.filter ?? []) params.append("filter", filter);
259
+ const { data } = await readPublicJson(
260
+ absoluteUrl(options.baseUrl, `${href}?${params}`),
261
+ sdkSchemas.PublishedCollection,
262
+ httpOptions,
263
+ );
264
+ return data;
265
+ };
266
+ // The public GraphQL content field has no filter argument.
267
+ if (list.filter?.length) return readHttp();
268
+ return preferGraphql(
269
+ async () => {
270
+ const data = await graphqlRequest<{
271
+ content: { records: unknown[]; nextCursor: string | null };
272
+ }>(
273
+ graphqlEndpoint,
274
+ contentQuery,
275
+ { type: list.type, cursor: list.cursor, limit },
276
+ httpOptions,
277
+ );
278
+ return parseGraphqlContent(list.type, data, limit);
279
+ },
280
+ readHttp,
281
+ );
282
+ };
283
+
284
+ const getContentRecord = async (id: string): Promise<SdkPublishedRecord> => {
285
+ const { href } = await stationFor("content", "content");
286
+ return preferGraphql(
287
+ async () => {
288
+ const data = await graphqlRequest<{ contentRecord: unknown }>(
289
+ graphqlEndpoint,
290
+ contentRecordQuery,
291
+ { id },
292
+ httpOptions,
293
+ );
294
+ return sdkSchemas.PublishedRecord.parse({
295
+ contractVersion: 1,
296
+ ...(data.contentRecord as object),
297
+ });
298
+ },
299
+ async () => {
300
+ const { data } = await readPublicJson(
301
+ absoluteUrl(options.baseUrl, `${href}/${encodeURIComponent(id)}`),
302
+ sdkSchemas.PublishedRecord,
303
+ httpOptions,
304
+ );
305
+ return data;
306
+ },
307
+ );
308
+ };
309
+
310
+ async function* iterateContent(
311
+ list: Omit<ContentListOptions, "cursor">,
312
+ ): AsyncGenerator<SdkPublishedRecord> {
313
+ const seen = new Set<string>();
314
+ let cursor: string | undefined;
315
+ do {
316
+ const page = await getContent({ ...list, cursor });
317
+ for (const record of page.records) yield record;
318
+ if (!page.nextCursor || seen.has(page.nextCursor)) return;
319
+ seen.add(page.nextCursor);
320
+ cursor = page.nextCursor;
321
+ } while (cursor);
322
+ }
323
+
324
+ const getSchedule = async (window: ScheduleWindow): Promise<SdkPublicSchedule> => {
325
+ assertScheduleWindow(window);
326
+ const { href } = await stationFor("schedule", "schedule");
327
+ return preferGraphql(
328
+ async () => {
329
+ const data = await graphqlRequest<{ schedule: unknown }>(
330
+ graphqlEndpoint,
331
+ scheduleQuery,
332
+ window,
333
+ httpOptions,
334
+ );
335
+ return parseGraphqlSchedule(data);
336
+ },
337
+ async () => {
338
+ const params = new URLSearchParams(window);
339
+ const { data } = await readPublicJson(
340
+ absoluteUrl(options.baseUrl, `${href}?${params}`),
341
+ sdkSchemas.PublicSchedule,
342
+ httpOptions,
343
+ );
344
+ return data;
345
+ },
346
+ );
347
+ };
348
+
349
+ const getPrograms = async (window: ScheduleWindow): Promise<SdkPublicPrograms> => {
350
+ assertScheduleWindow(window);
351
+ const { href } = await stationFor("programs", "programs");
352
+ return preferGraphql(
353
+ async () => {
354
+ const data = await graphqlRequest<{ programs: unknown }>(
355
+ graphqlEndpoint,
356
+ programsQuery,
357
+ window,
358
+ httpOptions,
359
+ );
360
+ return parseGraphqlPrograms(data);
361
+ },
362
+ async () => {
363
+ const params = new URLSearchParams(window);
364
+ const { data } = await readPublicJson(
365
+ absoluteUrl(options.baseUrl, `${href}?${params}`),
366
+ sdkSchemas.PublicPrograms,
367
+ httpOptions,
368
+ );
369
+ return data;
370
+ },
371
+ );
372
+ };
373
+
374
+ const getCurrentBroadcast = async (): Promise<SdkPublicCurrentBroadcast> => {
375
+ const { href } = await stationFor("broadcast", "broadcast");
376
+ return preferGraphql(
377
+ async () => {
378
+ const data = await graphqlRequest<{ currentBroadcast: unknown }>(
379
+ graphqlEndpoint,
380
+ currentBroadcastQuery,
381
+ undefined,
382
+ httpOptions,
383
+ );
384
+ return parseGraphqlCurrentBroadcast(data);
385
+ },
386
+ async () => {
387
+ const { data } = await readPublicJson(
388
+ absoluteUrl(options.baseUrl, href),
389
+ sdkSchemas.PublicCurrentBroadcast,
390
+ httpOptions,
391
+ );
392
+ return data;
393
+ },
394
+ );
395
+ };
396
+
397
+ const getNowPlaying = async (): Promise<SdkPublicNowPlaying> => {
398
+ const { href } = await stationFor("nowPlaying", "now-playing");
399
+ return preferGraphql(
400
+ async () => {
401
+ const data = await graphqlRequest<{ nowPlaying: unknown }>(
402
+ graphqlEndpoint,
403
+ nowPlayingQuery,
404
+ undefined,
405
+ httpOptions,
406
+ );
407
+ return parseGraphqlNowPlaying(data);
408
+ },
409
+ async () => {
410
+ const { data } = await readPublicJson(
411
+ absoluteUrl(options.baseUrl, href),
412
+ sdkSchemas.PublicNowPlaying,
413
+ httpOptions,
414
+ );
415
+ return data;
416
+ },
417
+ );
418
+ };
419
+
420
+ const getTrackHistory = async (
421
+ list: TrackHistoryOptions = {},
422
+ ): Promise<SdkPublicTrackHistory> => {
423
+ const { href } = await stationFor("trackHistory", "track-history");
424
+ const limit = list.limit ?? 25;
425
+ return preferGraphql(
426
+ async () => {
427
+ const data = await graphqlRequest<{ trackHistory: unknown }>(
428
+ graphqlEndpoint,
429
+ trackHistoryQuery,
430
+ { cursor: list.cursor, limit },
431
+ httpOptions,
432
+ );
433
+ return parseGraphqlTrackHistory(data);
434
+ },
435
+ async () => {
436
+ const params = new URLSearchParams({ limit: String(limit) });
437
+ if (list.cursor) params.set("cursor", list.cursor);
438
+ const { data } = await readPublicJson(
439
+ absoluteUrl(options.baseUrl, `${href}?${params}`),
440
+ sdkSchemas.PublicTrackHistory,
441
+ httpOptions,
442
+ );
443
+ return data;
444
+ },
445
+ );
446
+ };
447
+
448
+ async function* iterateTrackHistory(
449
+ list: Omit<TrackHistoryOptions, "cursor"> = {},
450
+ ): AsyncGenerator<SdkPublicTrackHistory["items"][number]> {
451
+ const seen = new Set<string>();
452
+ let cursor: string | undefined;
453
+ do {
454
+ const page = await getTrackHistory({ ...list, cursor });
455
+ for (const item of page.items) yield item;
456
+ if (!page.nextCursor || seen.has(page.nextCursor)) return;
457
+ seen.add(page.nextCursor);
458
+ cursor = page.nextCursor;
459
+ } while (cursor);
460
+ }
461
+
462
+ const getWeather = async (): Promise<SdkWeatherPublic | null> => {
463
+ const station = await optionalStationResource("weather");
464
+ if (!station?.resources.weather) return null;
465
+ const weatherHref = station.resources.weather;
466
+ return preferGraphql(
467
+ async () => {
468
+ const data = await graphqlRequest<{ weather: unknown }>(
469
+ graphqlEndpoint,
470
+ weatherQuery,
471
+ undefined,
472
+ httpOptions,
473
+ );
474
+ return parseGraphqlWeather(data);
475
+ },
476
+ async () => {
477
+ const { data } = await readPublicJson(
478
+ absoluteUrl(options.baseUrl, weatherHref),
479
+ sdkSchemas.WeatherPublic,
480
+ httpOptions,
481
+ );
482
+ return data;
483
+ },
484
+ );
485
+ };
486
+
487
+ const getTraffic = async (): Promise<SdkTrafficPublic | null> => {
488
+ const station = await optionalStationResource("traffic");
489
+ if (!station?.resources.traffic) return null;
490
+ const trafficHref = station.resources.traffic;
491
+ return preferGraphql(
492
+ async () => {
493
+ const data = await graphqlRequest<{ traffic: unknown }>(
494
+ graphqlEndpoint,
495
+ trafficQuery,
496
+ undefined,
497
+ httpOptions,
498
+ );
499
+ return parseGraphqlTraffic(data);
500
+ },
501
+ async () => {
502
+ const { data } = await readPublicJson(
503
+ absoluteUrl(options.baseUrl, trafficHref),
504
+ sdkSchemas.TrafficPublic,
505
+ httpOptions,
506
+ );
507
+ return data;
508
+ },
509
+ );
510
+ };
511
+
512
+ return {
513
+ getStation,
514
+ getSchemas,
515
+ getContent,
516
+ getContentRecord,
517
+ iterateContent,
518
+ getSchedule,
519
+ getPrograms,
520
+ getCurrentBroadcast,
521
+ getNowPlaying,
522
+ getTrackHistory,
523
+ iterateTrackHistory,
524
+ getWeather,
525
+ getTraffic,
526
+ recommendedPollIntervalMs: async () =>
527
+ (stationCache ?? (await getStation())).delivery.pollIntervalSeconds * 1000,
528
+ previewEditorial: (grant, input) =>
529
+ previewEditorial({
530
+ baseUrl: options.baseUrl,
531
+ grant,
532
+ apiToken: listenerToken || undefined,
533
+ input,
534
+ fetch: options.fetch,
535
+ transport: useGraphql(options) ? "graphql" : "http",
536
+ stationSlug: options.stationSlug,
537
+ retries: options.retries,
538
+ }),
539
+ reportPlayback: (observation, target) => {
540
+ if (!ingestToken) throw new Error("Playback ingest token (ingest:write) is required");
541
+ if (target?.stationId) {
542
+ return reportPlayback({
543
+ baseUrl: options.baseUrl,
544
+ ingestToken,
545
+ stationId: target.stationId,
546
+ observation,
547
+ fetch: options.fetch,
548
+ retries: options.retries,
549
+ });
550
+ }
551
+ return reportPlayback({
552
+ baseUrl: options.baseUrl,
553
+ ingestToken,
554
+ stationSlug: options.stationSlug,
555
+ observation,
556
+ fetch: options.fetch,
557
+ retries: options.retries,
558
+ });
559
+ },
560
+ subscribe: (handlers, window) => {
561
+ requireListenerToken();
562
+ let inner: PublicSocketSubscription | null = null;
563
+ let closed = false;
564
+ void getStation()
565
+ .then((station) => {
566
+ if (closed) return;
567
+ const socketUrl = requirePublicHref(station.resources.socket, "socket");
568
+ inner = subscribePublicSocket({
569
+ socketUrl,
570
+ apiToken: listenerToken,
571
+ createSocket: options.createSocket,
572
+ radioToolsOrigin: options.baseUrl,
573
+ fromDate: window?.fromDate,
574
+ throughDate: window?.throughDate,
575
+ handlers,
576
+ });
577
+ })
578
+ .catch((error) => handlers.onError?.(error));
579
+ return {
580
+ close: () => {
581
+ closed = true;
582
+ inner?.close();
583
+ },
584
+ };
585
+ },
586
+ };
587
+ }
package/src/env.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { type CreateStationClientOptions, createStationClient, type StationClient } from "./client";
2
+
3
+ export type RadioToolsEnv = {
4
+ RADIOTOOLS_BASE_URL?: string;
5
+ RADIOTOOLS_STATION_SLUG?: string;
6
+ RADIOTOOLS_API_TOKEN?: string;
7
+ RADIOTOOLS_INGEST_TOKEN?: string;
8
+ };
9
+
10
+ function envValue(env: RadioToolsEnv | undefined, key: keyof RadioToolsEnv): string | undefined {
11
+ if (env) return env[key];
12
+ return process.env[key];
13
+ }
14
+
15
+ export function stationClientOptionsFromEnv(env?: RadioToolsEnv): CreateStationClientOptions {
16
+ const baseUrl = envValue(env, "RADIOTOOLS_BASE_URL")?.trim();
17
+ const stationSlug = envValue(env, "RADIOTOOLS_STATION_SLUG")?.trim();
18
+ const apiToken = envValue(env, "RADIOTOOLS_API_TOKEN")?.trim();
19
+ const ingestToken = envValue(env, "RADIOTOOLS_INGEST_TOKEN")?.trim();
20
+ if (!baseUrl || !stationSlug || (!apiToken && !ingestToken)) {
21
+ throw new Error(
22
+ "Set RADIOTOOLS_BASE_URL, RADIOTOOLS_STATION_SLUG, and RADIOTOOLS_API_TOKEN or RADIOTOOLS_INGEST_TOKEN.",
23
+ );
24
+ }
25
+ return {
26
+ baseUrl,
27
+ stationSlug,
28
+ ...(apiToken ? { apiToken } : {}),
29
+ ...(ingestToken ? { ingestToken } : {}),
30
+ };
31
+ }
32
+
33
+ export function createStationClientFromEnv(
34
+ env?: RadioToolsEnv,
35
+ overrides: Partial<CreateStationClientOptions> = {},
36
+ ): StationClient {
37
+ return createStationClient({ ...stationClientOptionsFromEnv(env), ...overrides });
38
+ }