@freqhole/api-client 0.3.5 → 0.3.7

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,6 @@
1
1
  {
2
2
  "name": "@freqhole/api-client",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -975,6 +975,71 @@ export class WasmTransport implements Transport {
975
975
  return url;
976
976
  }
977
977
 
978
+ /**
979
+ * stream a blob's bytes straight to a caller-supplied sink, without ever
980
+ * assembling the whole blob in one buffer here - see the `Transport`
981
+ * interface doc comment for why this exists (docs/blob-transfer-opfs-
982
+ * and-sha256-refactor-plan.md phase 2). only the verified iroh-blobs
983
+ * path (blake3 required) is supported - callers without a blake3 should
984
+ * use `getBlobUrlWithProgress`/`fetchBlob` instead, same as before.
985
+ */
986
+ async streamBlobToSink(
987
+ blobId: string,
988
+ onChunk: (chunk: Uint8Array, offset: number) => void | Promise<void>,
989
+ onProgress: (received: number, total: number) => void,
990
+ blake3?: string,
991
+ totalBytes?: number,
992
+ mimeType?: string,
993
+ ): Promise<{ contentType: string; totalBytes: number }> {
994
+ if (!blake3) {
995
+ throw new TransportError(
996
+ `streamBlobToSink requires a blake3 hash for verified streaming (blob ${blobId})`,
997
+ );
998
+ }
999
+ const streamingFn =
1000
+ this.node.download_verified_streaming_with_ensure ?? this.node.download_verified_streaming;
1001
+ if (!streamingFn) {
1002
+ throw new TransportError(
1003
+ `streamBlobToSink: node has no streaming download method (blob ${blobId})`,
1004
+ );
1005
+ }
1006
+
1007
+ let totalReceived = 0;
1008
+ // chunk delivery is fire-and-forget from the wasm side (see
1009
+ // download_verified_streaming_with_ensure's own doc comment) - queue
1010
+ // each onChunk call after the previous one finishes so a slow sink
1011
+ // (e.g. an OPFS write) applies real backpressure instead of racing
1012
+ // ahead of disk writes.
1013
+ let writeChain: Promise<void> = Promise.resolve();
1014
+
1015
+ await streamingFn.call(
1016
+ this.node,
1017
+ this.peerAddr,
1018
+ blake3,
1019
+ totalBytes ?? 0,
1020
+ (chunk: Uint8Array, offset: number) => {
1021
+ // chunk is a wasm-owned Uint8Array view - copy to detach before
1022
+ // it's handed off across the write chain / a later tick.
1023
+ const owned = new Uint8Array(chunk.length);
1024
+ owned.set(chunk);
1025
+ totalReceived += owned.length;
1026
+ writeChain = writeChain.then(() => onChunk(owned, offset));
1027
+ if (!totalBytes || totalBytes <= 0) {
1028
+ onProgress(totalReceived, totalReceived);
1029
+ }
1030
+ },
1031
+ (fraction: number) => {
1032
+ if (totalBytes && totalBytes > 0) {
1033
+ const received = Math.min(totalBytes, Math.floor(fraction * totalBytes));
1034
+ onProgress(received, totalBytes);
1035
+ }
1036
+ },
1037
+ );
1038
+ await writeChain;
1039
+
1040
+ return { contentType: mimeType ?? "application/octet-stream", totalBytes: totalReceived };
1041
+ }
1042
+
978
1043
  /**
979
1044
  * revoke a blob URL to free memory
980
1045
  */
@@ -40,6 +40,7 @@ export const routes = {
40
40
  radio_public_blob_thumbnail: { method: 'GET', path: '/api/radio/stations/{station_id}/blobs/{blob_id}/thumb/{size}', req: null, resp: null, auth: { type: 'public' } as const },
41
41
  radio_public_timeline: { method: 'GET', path: '/api/radio/stations/{station_id}/timeline', req: null, resp: s.PublicTimelineManifestSchema, auth: { type: 'public' } as const },
42
42
  radio_stations: { method: 'GET', path: '/api/radio/stations', req: null, resp: s.RadioStationsResponseSchema, auth: { type: 'public' } as const },
43
+ radio_stations_full: { method: 'GET', path: '/api/radio/stations/full', req: null, resp: s.RadioStationsResponseSchema, auth: { type: 'authenticated' } as const },
43
44
  server_info: { method: 'GET', path: '/api/hello', req: null, resp: s.ServerInfoResponseSchema, auth: { type: 'public' } as const },
44
45
  },
45
46
  auth: {
@@ -1508,7 +1508,8 @@ export const CreateStationRequestSchema = z.object({
1508
1508
  encode_args: z.string().nullish(),
1509
1509
  codec: z.string().nullish(),
1510
1510
  play_mode: z.string().nullish(),
1511
- timeline_only_mode: z.boolean().nullish()
1511
+ timeline_only_mode: z.boolean().nullish(),
1512
+ content_mode: z.string().nullish()
1512
1513
  });
1513
1514
  export type CreateStationRequest = z.infer<typeof CreateStationRequestSchema>;
1514
1515
 
@@ -5190,6 +5191,7 @@ export const PublicAssetRefSchema = z.object({
5190
5191
  export type PublicAssetRef = z.infer<typeof PublicAssetRefSchema>;
5191
5192
 
5192
5193
  export const PublicNowPlayingSchema = z.object({
5194
+ kind: z.string(),
5193
5195
  song_id: z.string(),
5194
5196
  title: z.string(),
5195
5197
  artist: z.string().nullish(),
@@ -5210,7 +5212,9 @@ export const PublicStationSchema = z.object({
5210
5212
  listener_count: z.number(),
5211
5213
  is_default: z.boolean(),
5212
5214
  is_public: z.boolean(),
5215
+ is_running: z.boolean(),
5213
5216
  now_playing: z.object({
5217
+ kind: z.string(),
5214
5218
  song_id: z.string(),
5215
5219
  title: z.string(),
5216
5220
  artist: z.string().nullish(),
@@ -5391,7 +5395,8 @@ export type QueryVideosRequest = z.infer<typeof QueryVideosRequestSchema>;
5391
5395
  export const RadioBumperSchema = z.object({
5392
5396
  id: z.string(),
5393
5397
  station_id: z.string(),
5394
- song_id: z.string(),
5398
+ song_id: z.string().nullish(),
5399
+ video_id: z.string().nullish(),
5395
5400
  label: z.string(),
5396
5401
  weight: z.number(),
5397
5402
  created_at: z.number()
@@ -5400,7 +5405,8 @@ export type RadioBumper = z.infer<typeof RadioBumperSchema>;
5400
5405
 
5401
5406
  export const RadioBumpersAddRequestSchema = z.object({
5402
5407
  station_id: z.string(),
5403
- song_id: z.string(),
5408
+ song_id: z.string().nullish(),
5409
+ video_id: z.string().nullish(),
5404
5410
  label: z.string(),
5405
5411
  weight: z.number().nullish()
5406
5412
  });
@@ -5424,8 +5430,12 @@ export type RadioBumpersSetFrequencyRequest = z.infer<typeof RadioBumpersSetFreq
5424
5430
 
5425
5431
  export const RadioConfigPayloadSchema = z.object({
5426
5432
  enabled: z.boolean(),
5427
- encode_args: z.string(),
5428
- ffmpeg_available: z.boolean()
5433
+ encode_args: z.string().nullish(),
5434
+ video_encode_args: z.string().nullish(),
5435
+ video_codec: z.string().nullish(),
5436
+ ffmpeg_available: z.boolean(),
5437
+ max_concurrent_audio_streams: z.number(),
5438
+ max_concurrent_video_streams: z.number()
5429
5439
  });
5430
5440
  export type RadioConfigPayload = z.infer<typeof RadioConfigPayloadSchema>;
5431
5441
 
@@ -5451,7 +5461,9 @@ export const RadioInfoResponseSchema = z.object({
5451
5461
  listener_count: z.number(),
5452
5462
  is_default: z.boolean(),
5453
5463
  is_public: z.boolean(),
5464
+ is_running: z.boolean(),
5454
5465
  now_playing: z.object({
5466
+ kind: z.string(),
5455
5467
  song_id: z.string(),
5456
5468
  title: z.string(),
5457
5469
  artist: z.string().nullish(),
@@ -5493,6 +5505,8 @@ export const RadioStationSchema = z.object({
5493
5505
  codec: z.string(),
5494
5506
  play_mode: z.string(),
5495
5507
  timeline_only_mode: z.number(),
5508
+ content_mode: z.string(),
5509
+ bumper_frequency_seconds: z.number().nullish(),
5496
5510
  created_at: z.number(),
5497
5511
  updated_at: z.number()
5498
5512
  });
@@ -5530,7 +5544,9 @@ export const RadioStationsResponseSchema = z.object({
5530
5544
  listener_count: z.number(),
5531
5545
  is_default: z.boolean(),
5532
5546
  is_public: z.boolean(),
5547
+ is_running: z.boolean(),
5533
5548
  now_playing: z.object({
5549
+ kind: z.string(),
5534
5550
  song_id: z.string(),
5535
5551
  title: z.string(),
5536
5552
  artist: z.string().nullish(),
@@ -7631,7 +7647,8 @@ export const UpdateStationRequestSchema = z.object({
7631
7647
  encode_args: z.string().nullish(),
7632
7648
  codec: z.string().nullish(),
7633
7649
  play_mode: z.string().nullish(),
7634
- timeline_only_mode: z.boolean().nullish()
7650
+ timeline_only_mode: z.boolean().nullish(),
7651
+ content_mode: z.string().nullish()
7635
7652
  });
7636
7653
  export type UpdateStationRequest = z.infer<typeof UpdateStationRequestSchema>;
7637
7654
 
@@ -7,7 +7,8 @@ export function createAppMethods(call: CallFn) {
7
7
  return {
8
8
  healthCheck: () => {
9
9
  return call(
10
- "app", "health_check",
10
+ "app",
11
+ "health_check",
11
12
  routes.app.health_check.resp,
12
13
  routes.app.health_check.req,
13
14
  routes.app.health_check.method,
@@ -17,7 +18,8 @@ export function createAppMethods(call: CallFn) {
17
18
 
18
19
  serverInfo: () => {
19
20
  return call(
20
- "app", "server_info",
21
+ "app",
22
+ "server_info",
21
23
  routes.app.server_info.resp,
22
24
  routes.app.server_info.req,
23
25
  routes.app.server_info.method,
@@ -27,7 +29,8 @@ export function createAppMethods(call: CallFn) {
27
29
 
28
30
  radioInfo: () => {
29
31
  return call(
30
- "app", "radio_info",
32
+ "app",
33
+ "radio_info",
31
34
  routes.app.radio_info.resp,
32
35
  routes.app.radio_info.req,
33
36
  routes.app.radio_info.method,
@@ -37,7 +40,8 @@ export function createAppMethods(call: CallFn) {
37
40
 
38
41
  radioStations: () => {
39
42
  return call(
40
- "app", "radio_stations",
43
+ "app",
44
+ "radio_stations",
41
45
  routes.app.radio_stations.resp,
42
46
  routes.app.radio_stations.req,
43
47
  routes.app.radio_stations.method,
@@ -45,10 +49,25 @@ export function createAppMethods(call: CallFn) {
45
49
  );
46
50
  },
47
51
 
52
+ // authenticated counterpart of radioStations() - includes non-public
53
+ // stations too, for a caller who actually resolved (real session or
54
+ // known iroh peer). see offal::music::radio's doc comment.
55
+ radioStationsFull: () => {
56
+ return call(
57
+ "app",
58
+ "radio_stations_full",
59
+ routes.app.radio_stations_full.resp,
60
+ routes.app.radio_stations_full.req,
61
+ routes.app.radio_stations_full.method,
62
+ routes.app.radio_stations_full.path,
63
+ );
64
+ },
65
+
48
66
  // get blob metadata for a station blob
49
67
  radioPublicBlob: (params: { station_id: string; blob_id: string }) => {
50
68
  return call(
51
- "app", "radio_public_blob",
69
+ "app",
70
+ "radio_public_blob",
52
71
  routes.app.radio_public_blob.resp,
53
72
  routes.app.radio_public_blob.req,
54
73
  routes.app.radio_public_blob.method,
@@ -60,7 +79,8 @@ export function createAppMethods(call: CallFn) {
60
79
  // stream the binary data for a station blob
61
80
  radioPublicBlobData: (params: { station_id: string; blob_id: string }) => {
62
81
  return call(
63
- "app", "radio_public_blob_data",
82
+ "app",
83
+ "radio_public_blob_data",
64
84
  routes.app.radio_public_blob_data.resp,
65
85
  routes.app.radio_public_blob_data.req,
66
86
  routes.app.radio_public_blob_data.method,
@@ -72,7 +92,8 @@ export function createAppMethods(call: CallFn) {
72
92
  // get a thumbnail for a station blob at a given size
73
93
  radioPublicBlobThumbnail: (params: { station_id: string; blob_id: string; size: string }) => {
74
94
  return call(
75
- "app", "radio_public_blob_thumbnail",
95
+ "app",
96
+ "radio_public_blob_thumbnail",
76
97
  routes.app.radio_public_blob_thumbnail.resp,
77
98
  routes.app.radio_public_blob_thumbnail.req,
78
99
  routes.app.radio_public_blob_thumbnail.method,
@@ -84,7 +105,8 @@ export function createAppMethods(call: CallFn) {
84
105
  // get the public timeline manifest for a station
85
106
  radioPublicTimeline: (params: { station_id: string }) => {
86
107
  return call(
87
- "app", "radio_public_timeline",
108
+ "app",
109
+ "radio_public_timeline",
88
110
  routes.app.radio_public_timeline.resp,
89
111
  routes.app.radio_public_timeline.req,
90
112
  routes.app.radio_public_timeline.method,
package/src/transport.ts CHANGED
@@ -137,6 +137,34 @@ export interface Transport {
137
137
  opts?: BlobFetchOptions,
138
138
  ): Promise<string>;
139
139
 
140
+ /**
141
+ * stream a blob's bytes to a caller-supplied sink, chunk by chunk,
142
+ * instead of assembling the whole blob in memory first (optional).
143
+ * only implemented by WasmTransport - HTTP callers should stream
144
+ * directly from `fetch()`'s own `response.body` reader instead (see
145
+ * `streamVideoToOPFSWithResume`), and CharnelLocalTransport's sync-to-
146
+ * local path never goes through JS at all (grimoire's own disk-backed
147
+ * pull). intended for a caller writing straight to OPFS (or anywhere
148
+ * else) without ever holding the full blob in one buffer - see
149
+ * docs/blob-transfer-opfs-and-sha256-refactor-plan.md phase 2.
150
+ * @param blobId - the blob ID to fetch
151
+ * @param onChunk - called with each chunk as it arrives, in order
152
+ * @param onProgress - callback with (received, total) bytes
153
+ * @param blake3 - optional blake3 hash for verified streaming via iroh-blobs
154
+ * @param totalBytes - optional known total size in bytes
155
+ * @param mimeType - optional content type (midden's streaming path doesn't
156
+ * surface the source mime)
157
+ * @returns the content type and total bytes actually streamed
158
+ */
159
+ streamBlobToSink?(
160
+ blobId: string,
161
+ onChunk: (chunk: Uint8Array, offset: number) => void | Promise<void>,
162
+ onProgress: (received: number, total: number) => void,
163
+ blake3?: string,
164
+ totalBytes?: number,
165
+ mimeType?: string,
166
+ ): Promise<{ contentType: string; totalBytes: number }>;
167
+
140
168
  /**
141
169
  * fetch server image (public, no auth required)
142
170
  * used during "add remote" flow before user is authenticated