@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 +31 -4
- package/src/client.ts +587 -0
- package/src/env.ts +38 -0
- package/src/graphql.ts +297 -0
- package/src/http.ts +167 -0
- package/src/index.ts +44 -0
- package/src/media.ts +14 -0
- package/src/playback.ts +58 -0
- package/src/preview.ts +65 -0
- package/src/realtime.ts +120 -0
- package/src/schemas.ts +725 -0
- package/README.md +0 -3
package/src/graphql.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type { PublicJsonRequestOptions } from "./http";
|
|
2
|
+
import { postJson, RadioToolsApiError } from "./http";
|
|
3
|
+
import { sdkSchemas } from "./schemas";
|
|
4
|
+
|
|
5
|
+
export type GraphqlRequestOptions = PublicJsonRequestOptions & {
|
|
6
|
+
previewGrant?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type GraphqlEnvelope<T> = {
|
|
10
|
+
data?: T;
|
|
11
|
+
errors?: { message: string; extensions?: { code?: string } }[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function graphqlRequest<TData>(
|
|
15
|
+
endpoint: string,
|
|
16
|
+
query: string,
|
|
17
|
+
variables: Record<string, unknown> | undefined,
|
|
18
|
+
options: GraphqlRequestOptions = {},
|
|
19
|
+
): Promise<TData> {
|
|
20
|
+
const headers: Record<string, string> = {};
|
|
21
|
+
if (options.apiToken) headers.authorization = `Bearer ${options.apiToken}`;
|
|
22
|
+
if (options.previewGrant) headers["x-preview-grant"] = options.previewGrant;
|
|
23
|
+
const body = await postJson<GraphqlEnvelope<TData>>(
|
|
24
|
+
endpoint,
|
|
25
|
+
{ query, variables },
|
|
26
|
+
{ parse: (value) => value as GraphqlEnvelope<TData> },
|
|
27
|
+
{
|
|
28
|
+
fetch: options.fetch,
|
|
29
|
+
headers,
|
|
30
|
+
cache: options.cache ?? "no-store",
|
|
31
|
+
credentials: options.credentials ?? "omit",
|
|
32
|
+
retries: options.retries,
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
if (body.errors?.length) {
|
|
36
|
+
const code = body.errors[0]?.extensions?.code ?? "INTERNAL_ERROR";
|
|
37
|
+
throw Object.assign(new Error(body.errors[0]?.message ?? "GraphQL error"), {
|
|
38
|
+
name: "RadioToolsGraphqlError",
|
|
39
|
+
code,
|
|
40
|
+
errors: body.errors,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (!body.data) throw new Error("GraphQL response missing data");
|
|
44
|
+
return body.data;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isGraphqlUnavailable(error: unknown): boolean {
|
|
48
|
+
if (error instanceof RadioToolsApiError) return error.status === 404;
|
|
49
|
+
return error instanceof Error && error.message.includes("(404)");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const broadcastFields = `
|
|
53
|
+
id
|
|
54
|
+
title
|
|
55
|
+
startsAt
|
|
56
|
+
endsAt
|
|
57
|
+
timezone
|
|
58
|
+
localDate
|
|
59
|
+
resolution
|
|
60
|
+
status
|
|
61
|
+
cancellationReason
|
|
62
|
+
repeatOfId
|
|
63
|
+
program { id title profileId }
|
|
64
|
+
presenters { id title }
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
export const stationQuery = /* GraphQL */ `
|
|
68
|
+
query SdkStation {
|
|
69
|
+
station {
|
|
70
|
+
contractVersion
|
|
71
|
+
id
|
|
72
|
+
slug
|
|
73
|
+
name
|
|
74
|
+
timezone
|
|
75
|
+
publishingLanguage
|
|
76
|
+
resources {
|
|
77
|
+
schemas
|
|
78
|
+
content
|
|
79
|
+
schedule
|
|
80
|
+
programs
|
|
81
|
+
broadcast
|
|
82
|
+
nowPlaying
|
|
83
|
+
trackHistory
|
|
84
|
+
socket
|
|
85
|
+
weather
|
|
86
|
+
traffic
|
|
87
|
+
media
|
|
88
|
+
}
|
|
89
|
+
delivery { cache conditional pollIntervalSeconds }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
export const schemasQuery = /* GraphQL */ `
|
|
95
|
+
query SdkSchemas {
|
|
96
|
+
schemas { key label version archived fields }
|
|
97
|
+
}
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
export const contentQuery = /* GraphQL */ `
|
|
101
|
+
query SdkContent($type: String!, $cursor: ID, $limit: Int) {
|
|
102
|
+
content(type: $type, cursor: $cursor, limit: $limit) {
|
|
103
|
+
nextCursor
|
|
104
|
+
records {
|
|
105
|
+
id
|
|
106
|
+
publicationId
|
|
107
|
+
revision
|
|
108
|
+
type
|
|
109
|
+
schemaVersion
|
|
110
|
+
publishedAt
|
|
111
|
+
values
|
|
112
|
+
document
|
|
113
|
+
media { assetId versionId kind url credits mime }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
export const contentRecordQuery = /* GraphQL */ `
|
|
120
|
+
query SdkContentRecord($id: ID!) {
|
|
121
|
+
contentRecord(id: $id) {
|
|
122
|
+
id
|
|
123
|
+
publicationId
|
|
124
|
+
revision
|
|
125
|
+
type
|
|
126
|
+
schemaVersion
|
|
127
|
+
publishedAt
|
|
128
|
+
values
|
|
129
|
+
document
|
|
130
|
+
media { assetId versionId kind url credits mime }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
export const nowPlayingQuery = /* GraphQL */ `
|
|
136
|
+
query SdkNowPlaying {
|
|
137
|
+
nowPlaying {
|
|
138
|
+
contractVersion
|
|
139
|
+
stationId
|
|
140
|
+
timezone
|
|
141
|
+
track {
|
|
142
|
+
artist
|
|
143
|
+
title
|
|
144
|
+
album
|
|
145
|
+
durationSeconds
|
|
146
|
+
artworkUrl
|
|
147
|
+
isPlaying
|
|
148
|
+
observedAt
|
|
149
|
+
receivedAt
|
|
150
|
+
}
|
|
151
|
+
elapsedSeconds
|
|
152
|
+
endsAt
|
|
153
|
+
freshness { status thresholdSeconds ageSeconds observedAt receivedAt }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
`;
|
|
157
|
+
|
|
158
|
+
export const trackHistoryQuery = /* GraphQL */ `
|
|
159
|
+
query SdkTrackHistory($cursor: String, $limit: Int) {
|
|
160
|
+
trackHistory(cursor: $cursor, limit: $limit) {
|
|
161
|
+
contractVersion
|
|
162
|
+
stationId
|
|
163
|
+
items {
|
|
164
|
+
id
|
|
165
|
+
artist
|
|
166
|
+
title
|
|
167
|
+
album
|
|
168
|
+
durationSeconds
|
|
169
|
+
artworkUrl
|
|
170
|
+
isPlaying
|
|
171
|
+
observedAt
|
|
172
|
+
receivedAt
|
|
173
|
+
}
|
|
174
|
+
nextCursor
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
export const scheduleQuery = /* GraphQL */ `
|
|
180
|
+
query SdkSchedule($fromDate: String!, $throughDate: String!) {
|
|
181
|
+
schedule(fromDate: $fromDate, throughDate: $throughDate) {
|
|
182
|
+
contractVersion
|
|
183
|
+
timezone
|
|
184
|
+
truncated
|
|
185
|
+
broadcasts { ${broadcastFields} }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
export const programsQuery = /* GraphQL */ `
|
|
191
|
+
query SdkPrograms($fromDate: String!, $throughDate: String!) {
|
|
192
|
+
programs(fromDate: $fromDate, throughDate: $throughDate) {
|
|
193
|
+
contractVersion
|
|
194
|
+
timezone
|
|
195
|
+
fromDate
|
|
196
|
+
throughDate
|
|
197
|
+
truncated
|
|
198
|
+
programs { id title profileId }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
export const currentBroadcastQuery = /* GraphQL */ `
|
|
204
|
+
query SdkCurrentBroadcast {
|
|
205
|
+
currentBroadcast {
|
|
206
|
+
contractVersion
|
|
207
|
+
timezone
|
|
208
|
+
asOf
|
|
209
|
+
broadcast { ${broadcastFields} }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
export const weatherQuery = /* GraphQL */ `
|
|
215
|
+
query SdkWeather {
|
|
216
|
+
weather { stationId timezone snapshots }
|
|
217
|
+
}
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
export const trafficQuery = /* GraphQL */ `
|
|
221
|
+
query SdkTraffic {
|
|
222
|
+
traffic { stationId snapshot }
|
|
223
|
+
}
|
|
224
|
+
`;
|
|
225
|
+
|
|
226
|
+
export const previewMutation = /* GraphQL */ `
|
|
227
|
+
mutation SdkPreview($input: PreviewInput!) {
|
|
228
|
+
previewEditorial(input: $input) {
|
|
229
|
+
id
|
|
230
|
+
revision
|
|
231
|
+
schemaVersion
|
|
232
|
+
values
|
|
233
|
+
document
|
|
234
|
+
media { assetId versionId kind url credits mime }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
export function parseGraphqlStation(data: { station: unknown }) {
|
|
240
|
+
return sdkSchemas.PublicStation.parse(data.station);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function parseGraphqlSchemas(data: { schemas: unknown[] }) {
|
|
244
|
+
return sdkSchemas.PublicSchemaList.parse({ contractVersion: 1, types: data.schemas });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function parseGraphqlContent(
|
|
248
|
+
type: string,
|
|
249
|
+
data: { content: { records: unknown[]; nextCursor: string | null } },
|
|
250
|
+
limit: number,
|
|
251
|
+
) {
|
|
252
|
+
const nextCursor = data.content.nextCursor;
|
|
253
|
+
return sdkSchemas.PublishedCollection.parse({
|
|
254
|
+
contractVersion: 1,
|
|
255
|
+
type,
|
|
256
|
+
records: data.content.records.map((record) => ({
|
|
257
|
+
contractVersion: 1,
|
|
258
|
+
...(record as object),
|
|
259
|
+
})),
|
|
260
|
+
nextCursor,
|
|
261
|
+
page: { limit, nextCursor },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function parseGraphqlSchedule(data: { schedule: unknown }) {
|
|
266
|
+
return sdkSchemas.PublicSchedule.parse(data.schedule);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function parseGraphqlPrograms(data: { programs: unknown }) {
|
|
270
|
+
return sdkSchemas.PublicPrograms.parse(data.programs);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function parseGraphqlCurrentBroadcast(data: { currentBroadcast: unknown }) {
|
|
274
|
+
return sdkSchemas.PublicCurrentBroadcast.parse(data.currentBroadcast);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function parseGraphqlNowPlaying(data: { nowPlaying: unknown }) {
|
|
278
|
+
return sdkSchemas.PublicNowPlaying.parse(data.nowPlaying);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function parseGraphqlTrackHistory(data: { trackHistory: unknown }) {
|
|
282
|
+
return sdkSchemas.PublicTrackHistory.parse(data.trackHistory);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function parseGraphqlWeather(data: { weather: unknown }) {
|
|
286
|
+
if (data.weather == null) return null;
|
|
287
|
+
return sdkSchemas.WeatherPublic.parse({ contractVersion: 1, ...(data.weather as object) });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function parseGraphqlTraffic(data: { traffic: unknown }) {
|
|
291
|
+
if (data.traffic == null) return null;
|
|
292
|
+
return sdkSchemas.TrafficPublic.parse({ contractVersion: 1, ...(data.traffic as object) });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function parseGraphqlPreview(data: { previewEditorial: unknown }) {
|
|
296
|
+
return sdkSchemas.RevisionPreview.parse(data.previewEditorial);
|
|
297
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { type SdkErrorCode, type SdkErrorResponse, sdkSchemas } from "./schemas";
|
|
2
|
+
|
|
3
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
4
|
+
|
|
5
|
+
export class RadioToolsApiError extends Error {
|
|
6
|
+
readonly code: SdkErrorCode | null;
|
|
7
|
+
readonly requestId: string | null;
|
|
8
|
+
readonly status: number;
|
|
9
|
+
readonly issues?: SdkErrorResponse["error"]["issues"];
|
|
10
|
+
|
|
11
|
+
constructor(status: number, body: SdkErrorResponse | { error: string }) {
|
|
12
|
+
super(typeof body.error === "string" ? body.error : body.error.message);
|
|
13
|
+
this.name = "RadioToolsApiError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
if (typeof body.error === "string") {
|
|
16
|
+
this.code = null;
|
|
17
|
+
this.requestId = null;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
this.code = body.error.code;
|
|
21
|
+
this.requestId = body.error.requestId;
|
|
22
|
+
this.issues = body.error.issues;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type EtagEntry<T> = { etag: string; data: T };
|
|
27
|
+
|
|
28
|
+
/** In-memory conditional GET cache. Keys include a hash of the listener token. */
|
|
29
|
+
export type EtagCache = Map<string, EtagEntry<unknown>>;
|
|
30
|
+
|
|
31
|
+
export function createEtagCache(): EtagCache {
|
|
32
|
+
return new Map();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type PublicJsonRequestOptions = {
|
|
36
|
+
fetch?: FetchLike;
|
|
37
|
+
/** Station credential with listener:read. Keep it on the station's server. */
|
|
38
|
+
apiToken?: string;
|
|
39
|
+
etagCache?: EtagCache;
|
|
40
|
+
cache?: RequestCache;
|
|
41
|
+
credentials?: RequestCredentials;
|
|
42
|
+
/** Extra attempts after a 429 or 503. Defaults to 2. */
|
|
43
|
+
retries?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const defaultPublicInit = {
|
|
47
|
+
cache: "no-store" as RequestCache,
|
|
48
|
+
credentials: "omit" as RequestCredentials,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function throwResponseError(status: number, body: unknown): never {
|
|
52
|
+
const structured = sdkSchemas.ErrorResponse.safeParse(body);
|
|
53
|
+
if (structured.success) throw new RadioToolsApiError(status, structured.data);
|
|
54
|
+
const ingest = sdkSchemas.IngestErrorResponse.safeParse(body);
|
|
55
|
+
if (ingest.success) throw new RadioToolsApiError(status, ingest.data);
|
|
56
|
+
throw new Error(`RadioTools request failed (${status})`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readErrorBody(response: Response): Promise<unknown> {
|
|
60
|
+
try {
|
|
61
|
+
return await response.json();
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error(`RadioTools request failed (${response.status})`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function retryDelayMs(response: Response, attempt: number): number {
|
|
68
|
+
const retryAfter = response.headers.get("retry-after");
|
|
69
|
+
if (retryAfter && /^\d+$/.test(retryAfter)) return Math.min(Number(retryAfter) * 1000, 5000);
|
|
70
|
+
return Math.min(250 * 2 ** (attempt - 1), 5000);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function sendWithRetry(
|
|
74
|
+
call: FetchLike,
|
|
75
|
+
url: string,
|
|
76
|
+
init: RequestInit,
|
|
77
|
+
retries: number,
|
|
78
|
+
): Promise<Response> {
|
|
79
|
+
let attempt = 0;
|
|
80
|
+
while (true) {
|
|
81
|
+
const response = await call(url, init);
|
|
82
|
+
const retryable = response.status === 429 || response.status === 503;
|
|
83
|
+
if (!retryable || attempt >= retries) return response;
|
|
84
|
+
attempt += 1;
|
|
85
|
+
const delay = retryDelayMs(response, attempt);
|
|
86
|
+
await response.body?.cancel().catch(() => undefined);
|
|
87
|
+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function tokenDigest(apiToken: string): Promise<string> {
|
|
92
|
+
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(apiToken));
|
|
93
|
+
return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function readPublicJson<T>(
|
|
97
|
+
url: string,
|
|
98
|
+
schema: { parse: (data: unknown) => T },
|
|
99
|
+
options: PublicJsonRequestOptions = {},
|
|
100
|
+
): Promise<{ data: T; etag: string | null; notModified: boolean }> {
|
|
101
|
+
const call = options.fetch ?? fetch;
|
|
102
|
+
const cache = options.etagCache;
|
|
103
|
+
const digest = options.apiToken ? await tokenDigest(options.apiToken) : "";
|
|
104
|
+
const cacheKey = `${url}#${digest}`;
|
|
105
|
+
const cached = cache?.get(cacheKey) as EtagEntry<T> | undefined;
|
|
106
|
+
const headers = new Headers();
|
|
107
|
+
if (options.apiToken) headers.set("authorization", `Bearer ${options.apiToken}`);
|
|
108
|
+
if (cached?.etag) headers.set("if-none-match", cached.etag);
|
|
109
|
+
|
|
110
|
+
const response = await sendWithRetry(
|
|
111
|
+
call,
|
|
112
|
+
url,
|
|
113
|
+
{
|
|
114
|
+
method: "GET",
|
|
115
|
+
headers,
|
|
116
|
+
cache: options.cache ?? defaultPublicInit.cache,
|
|
117
|
+
credentials: options.credentials ?? defaultPublicInit.credentials,
|
|
118
|
+
},
|
|
119
|
+
options.retries ?? 2,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (response.status === 304 && cached) {
|
|
123
|
+
return { data: cached.data, etag: cached.etag, notModified: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!response.ok) throwResponseError(response.status, await readErrorBody(response));
|
|
127
|
+
|
|
128
|
+
const data = schema.parse(await response.json());
|
|
129
|
+
const etag = response.headers.get("etag");
|
|
130
|
+
if (cache && etag) cache.set(cacheKey, { etag, data });
|
|
131
|
+
return { data, etag, notModified: false };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function postJson<T>(
|
|
135
|
+
url: string,
|
|
136
|
+
body: unknown,
|
|
137
|
+
schema: { parse: (data: unknown) => T },
|
|
138
|
+
init: {
|
|
139
|
+
fetch?: FetchLike;
|
|
140
|
+
headers?: HeadersInit;
|
|
141
|
+
cache?: RequestCache;
|
|
142
|
+
credentials?: RequestCredentials;
|
|
143
|
+
retries?: number;
|
|
144
|
+
rawBody?: string;
|
|
145
|
+
} = {},
|
|
146
|
+
): Promise<T> {
|
|
147
|
+
const call = init.fetch ?? fetch;
|
|
148
|
+
const payload = init.rawBody ?? JSON.stringify(body);
|
|
149
|
+
const response = await sendWithRetry(
|
|
150
|
+
call,
|
|
151
|
+
url,
|
|
152
|
+
{
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: (() => {
|
|
155
|
+
const headers = new Headers(init.headers);
|
|
156
|
+
headers.set("content-type", "application/json");
|
|
157
|
+
return headers;
|
|
158
|
+
})(),
|
|
159
|
+
body: payload,
|
|
160
|
+
cache: init.cache ?? "no-store",
|
|
161
|
+
credentials: init.credentials ?? "omit",
|
|
162
|
+
},
|
|
163
|
+
init.retries ?? 2,
|
|
164
|
+
);
|
|
165
|
+
if (!response.ok) throwResponseError(response.status, await readErrorBody(response));
|
|
166
|
+
return schema.parse(await response.json());
|
|
167
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ContentListOptions,
|
|
3
|
+
type CreateStationClientOptions,
|
|
4
|
+
createStationClient,
|
|
5
|
+
type ReportPlaybackTarget,
|
|
6
|
+
type ScheduleWindow,
|
|
7
|
+
type StationClient,
|
|
8
|
+
type StationClientTransport,
|
|
9
|
+
type TrackHistoryOptions,
|
|
10
|
+
} from "./client";
|
|
11
|
+
export { createStationClientFromEnv, type RadioToolsEnv, stationClientOptionsFromEnv } from "./env";
|
|
12
|
+
export { type EtagCache, type FetchLike, RadioToolsApiError, createEtagCache } from "./http";
|
|
13
|
+
export { type PublicMediaVariant, publicMediaUrl } from "./media";
|
|
14
|
+
export { type ReportPlaybackOptions, reportPlayback } from "./playback";
|
|
15
|
+
export { type PreviewEditorialOptions, previewEditorial } from "./preview";
|
|
16
|
+
export {
|
|
17
|
+
type PublicSocketHandlers,
|
|
18
|
+
type PublicSocketSubscription,
|
|
19
|
+
subscribePublicSocket,
|
|
20
|
+
} from "./realtime";
|
|
21
|
+
export {
|
|
22
|
+
LISTENER_CONTRACT_VERSION,
|
|
23
|
+
PUBLIC_SOCKET_PROTOCOL,
|
|
24
|
+
type SdkErrorCode,
|
|
25
|
+
type SdkErrorResponse,
|
|
26
|
+
type SdkPlaybackObservation,
|
|
27
|
+
type SdkPlaybackObservationResult,
|
|
28
|
+
type SdkPreviewInput,
|
|
29
|
+
type SdkPublicCurrentBroadcast,
|
|
30
|
+
type SdkPublicNowPlaying,
|
|
31
|
+
type SdkPublicOnAir,
|
|
32
|
+
type SdkPublicPrograms,
|
|
33
|
+
type SdkPublicSchedule,
|
|
34
|
+
type SdkPublicSchemaList,
|
|
35
|
+
type SdkPublicSocketEvent,
|
|
36
|
+
type SdkPublicStation,
|
|
37
|
+
type SdkPublicTrackHistory,
|
|
38
|
+
type SdkPublishedCollection,
|
|
39
|
+
type SdkPublishedRecord,
|
|
40
|
+
type SdkRevisionPreview,
|
|
41
|
+
type SdkTrafficPublic,
|
|
42
|
+
type SdkWeatherPublic,
|
|
43
|
+
sdkSchemas,
|
|
44
|
+
} from "./schemas";
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type PublicMediaVariant = "thumb" | "display";
|
|
2
|
+
|
|
3
|
+
/** Build a public media URL. RadioTools serves the bytes. The SDK does not download them. */
|
|
4
|
+
export function publicMediaUrl(
|
|
5
|
+
baseUrl: string,
|
|
6
|
+
mediaResourcePrefix: string,
|
|
7
|
+
versionId: string,
|
|
8
|
+
variant?: PublicMediaVariant,
|
|
9
|
+
): string {
|
|
10
|
+
const path = variant
|
|
11
|
+
? `${mediaResourcePrefix}/${versionId}/${variant}`
|
|
12
|
+
: `${mediaResourcePrefix}/${versionId}`;
|
|
13
|
+
return new URL(path, baseUrl).href;
|
|
14
|
+
}
|
package/src/playback.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { FetchLike } from "./http";
|
|
2
|
+
import { postJson } from "./http";
|
|
3
|
+
import {
|
|
4
|
+
type SdkPlaybackObservation,
|
|
5
|
+
type SdkPlaybackObservationResult,
|
|
6
|
+
sdkSchemas,
|
|
7
|
+
} from "./schemas";
|
|
8
|
+
|
|
9
|
+
const MAX_PLAYBACK_BODY_BYTES = 8192;
|
|
10
|
+
|
|
11
|
+
export type ReportPlaybackOptions = {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
/** Integration credential with integration name `playback` and scope `ingest:write`. */
|
|
14
|
+
ingestToken: string;
|
|
15
|
+
stationSlug?: string;
|
|
16
|
+
stationId?: string;
|
|
17
|
+
observation: SdkPlaybackObservation;
|
|
18
|
+
fetch?: FetchLike;
|
|
19
|
+
retries?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Send one encoder observation to `POST /api/v1/integrations/playback`.
|
|
24
|
+
* Exactly one of `stationSlug` or `stationId` is sent. A missing `eventId` is
|
|
25
|
+
* assigned once so retries of this call stay idempotent.
|
|
26
|
+
*/
|
|
27
|
+
export async function reportPlayback(
|
|
28
|
+
options: ReportPlaybackOptions,
|
|
29
|
+
): Promise<SdkPlaybackObservationResult> {
|
|
30
|
+
const ingestToken = options.ingestToken.trim();
|
|
31
|
+
if (!ingestToken) throw new Error("Playback ingest token is required");
|
|
32
|
+
if (options.stationId && options.stationSlug) {
|
|
33
|
+
throw new Error("Send exactly one of stationId or stationSlug");
|
|
34
|
+
}
|
|
35
|
+
if (!options.stationId && !options.stationSlug) {
|
|
36
|
+
throw new Error("stationSlug or stationId is required");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const parsed = sdkSchemas.PlaybackObservationIngestBody.parse(options.observation);
|
|
40
|
+
const body = { ...parsed, eventId: parsed.eventId ?? crypto.randomUUID() };
|
|
41
|
+
const payload = JSON.stringify(body);
|
|
42
|
+
if (new TextEncoder().encode(payload).byteLength > MAX_PLAYBACK_BODY_BYTES) {
|
|
43
|
+
throw new Error("Playback observation exceeds 8 KiB");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const url = new URL("/api/v1/integrations/playback", options.baseUrl);
|
|
47
|
+
if (options.stationId) url.searchParams.set("stationId", options.stationId);
|
|
48
|
+
else if (options.stationSlug) url.searchParams.set("stationSlug", options.stationSlug);
|
|
49
|
+
|
|
50
|
+
return postJson(url.href, body, sdkSchemas.PlaybackObservationResult, {
|
|
51
|
+
fetch: options.fetch,
|
|
52
|
+
headers: { authorization: `Bearer ${ingestToken}` },
|
|
53
|
+
cache: "no-store",
|
|
54
|
+
credentials: "omit",
|
|
55
|
+
retries: options.retries,
|
|
56
|
+
rawBody: payload,
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { graphqlRequest, parseGraphqlPreview, previewMutation } from "./graphql";
|
|
2
|
+
import type { FetchLike } from "./http";
|
|
3
|
+
import { postJson } from "./http";
|
|
4
|
+
import { type SdkPreviewInput, type SdkRevisionPreview, sdkSchemas } from "./schemas";
|
|
5
|
+
|
|
6
|
+
export type PreviewEditorialOptions = {
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
/** Short-lived preview grant. Pass it explicitly. Never read it from the query string. */
|
|
9
|
+
grant: string;
|
|
10
|
+
/** Required for GraphQL preview. REST preview uses only the grant. */
|
|
11
|
+
apiToken?: string;
|
|
12
|
+
input: SdkPreviewInput;
|
|
13
|
+
fetch?: FetchLike;
|
|
14
|
+
/** Listener site origin for browser preview CORS on the HTTP preview route. */
|
|
15
|
+
listenerOrigin?: string;
|
|
16
|
+
transport?: "http" | "graphql";
|
|
17
|
+
stationSlug?: string;
|
|
18
|
+
retries?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Preview unpublished editorial content with a Bearer grant.
|
|
23
|
+
* Studio session cookies are never sent.
|
|
24
|
+
*/
|
|
25
|
+
export async function previewEditorial(
|
|
26
|
+
options: PreviewEditorialOptions,
|
|
27
|
+
): Promise<SdkRevisionPreview> {
|
|
28
|
+
const grant = options.grant.trim();
|
|
29
|
+
if (!grant) throw new Error("Preview grant is required");
|
|
30
|
+
|
|
31
|
+
if (options.transport === "graphql") {
|
|
32
|
+
if (!options.stationSlug) throw new Error("stationSlug is required for GraphQL preview");
|
|
33
|
+
if (!options.apiToken) throw new Error("Listener API token is required for GraphQL preview");
|
|
34
|
+
const endpoint = new URL(
|
|
35
|
+
`/api/v1/stations/${encodeURIComponent(options.stationSlug)}/graphql`,
|
|
36
|
+
options.baseUrl,
|
|
37
|
+
).href;
|
|
38
|
+
const data = await graphqlRequest<{ previewEditorial: unknown }>(
|
|
39
|
+
endpoint,
|
|
40
|
+
previewMutation,
|
|
41
|
+
{ input: options.input },
|
|
42
|
+
{
|
|
43
|
+
fetch: options.fetch,
|
|
44
|
+
apiToken: options.apiToken,
|
|
45
|
+
previewGrant: grant,
|
|
46
|
+
credentials: "omit",
|
|
47
|
+
cache: "no-store",
|
|
48
|
+
retries: options.retries,
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
return parseGraphqlPreview(data);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const url = new URL("/api/v1/preview/editorial", options.baseUrl).href;
|
|
55
|
+
const headers: Record<string, string> = { authorization: `Bearer ${grant}` };
|
|
56
|
+
if (options.listenerOrigin) headers.origin = options.listenerOrigin;
|
|
57
|
+
|
|
58
|
+
return postJson(url, options.input, sdkSchemas.RevisionPreview, {
|
|
59
|
+
fetch: options.fetch,
|
|
60
|
+
headers,
|
|
61
|
+
cache: "no-store",
|
|
62
|
+
credentials: "omit",
|
|
63
|
+
retries: options.retries,
|
|
64
|
+
});
|
|
65
|
+
}
|