@freqhole/api-client 0.3.6 → 0.3.8

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.6",
3
+ "version": "0.3.8",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,106 @@
1
+ // tests for WasmTransport.upload()'s iroh-blobs chunked path - added while
2
+ // replacing a whole-file `file.arrayBuffer()` buffer-then-import call with
3
+ // genuine chunked streaming into `start_import()`'s ImportSession (no
4
+ // base64 involved at any point here - a direct wasm-bindgen call, unlike
5
+ // the tauri-IPC chunked path in CharnelTransport.ts). covers: chunking
6
+ // across multiple `push()` calls, real per-chunk progress, TempTag release,
7
+ // and the fallback to `import_blob` for older node builds without
8
+ // `start_import`.
9
+
10
+ import { describe, expect, it, vi } from "vitest";
11
+ import { WasmTransport } from "./WasmTransport";
12
+ import type { MiddenNodeLike } from "./WasmTransport";
13
+
14
+ function fakeFile(sizeBytes: number, name = "song.mp3"): File {
15
+ const bytes = new Uint8Array(sizeBytes).fill(7);
16
+ return new File([bytes], name, { type: "audio/mpeg" });
17
+ }
18
+
19
+ function fakeApiResponse() {
20
+ return {
21
+ status: 200,
22
+ body: JSON.stringify({ success: true, data: { job_id: "job-1" } }),
23
+ };
24
+ }
25
+
26
+ describe("WasmTransport.upload() via iroh-blobs (chunked, no base64)", () => {
27
+ it("streams a large file across multiple push() calls instead of one whole-buffer import_blob call", async () => {
28
+ const pushed: number[] = [];
29
+ const session = {
30
+ push: vi.fn(async (chunk: Uint8Array) => {
31
+ pushed.push(chunk.byteLength);
32
+ }),
33
+ finish: vi.fn(async () => "blake3hash"),
34
+ abort: vi.fn(),
35
+ };
36
+ const node: Partial<MiddenNodeLike> = {
37
+ node_id: () => "local-node",
38
+ import_blob: vi.fn(async () => "should-not-be-used"),
39
+ start_import: vi.fn(() => session),
40
+ release_blob: vi.fn(),
41
+ api_request: vi.fn(async () => fakeApiResponse()),
42
+ };
43
+ const transport = new WasmTransport(node as MiddenNodeLike, "peer-1");
44
+
45
+ // 4MB chunk size -> a 9MB file should push 3 chunks (4, 4, 1 MB).
46
+ const fileSize = 9 * 1024 * 1024;
47
+ const formData = new FormData();
48
+ formData.set("file", fakeFile(fileSize));
49
+
50
+ const onProgress = vi.fn();
51
+ await transport.upload("/api/upload/music", formData, onProgress);
52
+
53
+ expect(node.import_blob).not.toHaveBeenCalled();
54
+ expect(session.push).toHaveBeenCalledTimes(3);
55
+ expect(pushed.reduce((a, b) => a + b, 0)).toBe(fileSize);
56
+ expect(session.finish).toHaveBeenCalledTimes(1);
57
+ expect(node.release_blob).toHaveBeenCalledWith("blake3hash");
58
+
59
+ // progress is real and monotonically increasing, ending at the full size.
60
+ expect(onProgress).toHaveBeenCalled();
61
+ const calls = onProgress.mock.calls.map((c) => c[0] as number);
62
+ expect(calls[calls.length - 1]).toBe(fileSize);
63
+ expect([...calls].sort((a, b) => a - b)).toEqual(calls);
64
+ });
65
+
66
+ it("falls back to a single import_blob call when start_import isn't available", async () => {
67
+ const node: Partial<MiddenNodeLike> = {
68
+ node_id: () => "local-node",
69
+ import_blob: vi.fn(async () => "blake3hash"),
70
+ release_blob: vi.fn(),
71
+ api_request: vi.fn(async () => fakeApiResponse()),
72
+ };
73
+ const transport = new WasmTransport(node as MiddenNodeLike, "peer-1");
74
+
75
+ const formData = new FormData();
76
+ formData.set("file", fakeFile(1024));
77
+ await transport.upload("/api/upload/music", formData);
78
+
79
+ expect(node.import_blob).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it("aborts the import session and propagates the error if a push() fails", async () => {
83
+ const session = {
84
+ push: vi.fn(async () => {
85
+ throw new Error("push failed");
86
+ }),
87
+ finish: vi.fn(async () => "should-not-be-called"),
88
+ abort: vi.fn(),
89
+ };
90
+ const node: Partial<MiddenNodeLike> = {
91
+ node_id: () => "local-node",
92
+ import_blob: vi.fn(async () => "should-not-be-used"),
93
+ start_import: vi.fn(() => session),
94
+ release_blob: vi.fn(),
95
+ api_request: vi.fn(async () => fakeApiResponse()),
96
+ };
97
+ const transport = new WasmTransport(node as MiddenNodeLike, "peer-1");
98
+
99
+ const formData = new FormData();
100
+ formData.set("file", fakeFile(1024));
101
+
102
+ await expect(transport.upload("/api/upload/music", formData)).rejects.toThrow("upload failed");
103
+ expect(session.abort).toHaveBeenCalledTimes(1);
104
+ expect(session.finish).not.toHaveBeenCalled();
105
+ });
106
+ });
@@ -97,6 +97,12 @@ export interface MiddenNodeLike {
97
97
  download_verified?(peer_addr: string, blake3_hash: string): Promise<Uint8Array>;
98
98
  // download blob with automatic ensure + retry - optional
99
99
  download_verified_with_ensure?(peer_addr: string, blake3_hash: string): Promise<Uint8Array>;
100
+ // download blob DIRECTLY into this node's own local store, never
101
+ // returning bytes to JS - see
102
+ // `download_verified_to_store_with_ensure`'s doc comment in
103
+ // lib/midden/src/lib.rs. use instead of download_verified_with_ensure
104
+ // whenever the caller only needs the blob to become locally servable.
105
+ download_verified_to_store_with_ensure?(peer_addr: string, blake3_hash: string): Promise<void>;
100
106
  // download blob by ID with on-demand blake3 computation - optional
101
107
  // returns [Uint8Array, string] but typed as any[] for wasm-bindgen compatibility
102
108
  download_verified_by_id?(peer_addr: string, blob_id: string): Promise<any[]>;
@@ -153,6 +159,35 @@ export interface MiddenNodeLike {
153
159
  // phase 1. optional because charnel's CharnelTransport has no wasm
154
160
  // accept-loop equivalent (yet).
155
161
  accept?(): Promise<BiStreamLike | null>;
162
+ // snapshot of this node's own OUTGOING blob transfers currently in
163
+ // flight (this node serving a blob to a peer) - see
164
+ // docs/transfer-unification-plan.md phase 1 (bucket A). does NOT cover
165
+ // incoming/download transfers - iroh-blobs only instruments the serving
166
+ // side; downloads are tracked via the on_progress callbacks the
167
+ // download_verified_* methods above already take.
168
+ get_active_transfers?(): Promise<ActiveTransferLike[]>;
169
+ // pause every in-flight DOWNLOAD of this blake3 hash (no effect on
170
+ // outgoing/serving transfers - only the puller can pace a pull).
171
+ // the partial stays pinned in the local store; resuming just means
172
+ // calling a download_verified_* method again with the same hash.
173
+ // returns how many downloads were flagged (0 = none in flight).
174
+ download_cancel_by_blake3?(blake3_hash: string): Promise<number>;
175
+ // pin a hash against gc (keep a paused partial download alive).
176
+ protect_blob?(blake3_hash: string): Promise<void>;
177
+ // remove a gc pin added by protect_blob or a cancelled download.
178
+ unprotect_blob?(blake3_hash: string): Promise<void>;
179
+ }
180
+
181
+ /**
182
+ * one outgoing blob transfer in flight, as returned by
183
+ * `get_active_transfers()` - see `ActiveTransfer` in reliquary's
184
+ * `gate.rs` / `WorkerActiveTransfer` in reliquary's worker contract.
185
+ */
186
+ export interface ActiveTransferLike {
187
+ peerId: string;
188
+ blake3: string;
189
+ bytesSent: number;
190
+ totalSize: number;
156
191
  }
157
192
 
158
193
  /**
@@ -392,14 +427,9 @@ export class WasmTransport implements Transport {
392
427
  async upload(
393
428
  path: string,
394
429
  formData: FormData,
395
- _onProgress?: (loaded: number, total: number) => void,
430
+ onProgress?: (loaded: number, total: number) => void,
396
431
  metadata?: UploadMetadata,
397
432
  ): Promise<TransportResponse> {
398
- // no byte-level progress possible here - unlike CharnelTransport's tauri
399
- // IPC path (which self-chunks and can report per-chunk progress), the
400
- // midden wasm binding's `import_blob(bytes)` is a single opaque call
401
- // with no progress callback exposed, and the base64 fallback also sends
402
- // one JSON request.
403
433
  const file = formData.get("file") as File | null;
404
434
  if (!file) {
405
435
  return {
@@ -422,7 +452,7 @@ export class WasmTransport implements Transport {
422
452
  // available - chunked/verified streaming, no base64/raw-bytes framing.
423
453
  const blobPullPaths = ["/api/upload/music", "/api/upload/video"];
424
454
  if (blobPullPaths.includes(path) && this.node.import_blob) {
425
- return this.uploadViaIrohBlobs(path, file, metadata);
455
+ return this.uploadViaIrohBlobs(path, file, onProgress, metadata);
426
456
  }
427
457
 
428
458
  // fallback: base64 encode and send via api_request (works for image uploads)
@@ -435,21 +465,55 @@ export class WasmTransport implements Transport {
435
465
  * 2. tell remote peer the hash via `${path}-by-blake3` (e.g. /api/upload/music-by-blake3)
436
466
  * 3. remote peer pulls the blob from us via iroh-blobs verified streaming
437
467
  * 4. release the TempTag so local GC can reclaim the blob
468
+ *
469
+ * streams the file into `start_import()`'s chunked `ImportSession` in
470
+ * bounded slices (mirrors `CharnelTransport.uploadMediaViaBytes`'s same
471
+ * chunk size) instead of reading the whole file into one buffer via
472
+ * `file.arrayBuffer()` - this is a direct wasm-bindgen call (`push`
473
+ * takes a `Uint8Array` straight through, no JSON/IPC boundary to cross),
474
+ * so there's no base64 involved at any point, unlike the tauri-IPC
475
+ * chunked path. this also yields real per-chunk progress, previously
476
+ * unavailable here (a single opaque `import_blob(wholeBytes)` call had
477
+ * nothing to report between start and finish). falls back to the old
478
+ * whole-buffer `import_blob` call only if this node build predates
479
+ * `start_import` (progress-less in that case, matching prior behavior).
438
480
  */
439
481
  private async uploadViaIrohBlobs(
440
482
  path: string,
441
483
  file: File,
484
+ onProgress?: (loaded: number, total: number) => void,
442
485
  metadata?: UploadMetadata,
443
486
  ): Promise<TransportResponse> {
487
+ // ~4MB per chunk - same size CharnelTransport's tauri IPC chunking
488
+ // uses, though the reason differs (there it bounds IPC payload size;
489
+ // here it just bounds how much of the file is held at once).
490
+ const CHUNK_SIZE = 4 * 1024 * 1024;
444
491
  try {
445
- const fileBytes = new Uint8Array(await file.arrayBuffer());
446
- const hash = await this.node.import_blob!(fileBytes);
492
+ const hash = await (async () => {
493
+ if (!this.node.start_import) {
494
+ // old node build - no chunked import session available.
495
+ return this.node.import_blob!(new Uint8Array(await file.arrayBuffer()));
496
+ }
497
+ const session = this.node.start_import();
498
+ try {
499
+ for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
500
+ const slice = file.slice(offset, Math.min(offset + CHUNK_SIZE, file.size));
501
+ const chunk = new Uint8Array(await slice.arrayBuffer());
502
+ await session.push(chunk);
503
+ onProgress?.(Math.min(offset + chunk.byteLength, file.size), file.size);
504
+ }
505
+ return await session.finish();
506
+ } catch (err) {
507
+ session.abort();
508
+ throw err;
509
+ }
510
+ })();
447
511
 
448
512
  try {
449
513
  const body: Record<string, unknown> = {
450
514
  blake3: hash,
451
515
  filename: file.name,
452
- size: fileBytes.length,
516
+ size: file.size,
453
517
  ...metadata,
454
518
  };
455
519
 
@@ -975,6 +1039,71 @@ export class WasmTransport implements Transport {
975
1039
  return url;
976
1040
  }
977
1041
 
1042
+ /**
1043
+ * stream a blob's bytes straight to a caller-supplied sink, without ever
1044
+ * assembling the whole blob in one buffer here - see the `Transport`
1045
+ * interface doc comment for why this exists (docs/blob-transfer-opfs-
1046
+ * and-sha256-refactor-plan.md phase 2). only the verified iroh-blobs
1047
+ * path (blake3 required) is supported - callers without a blake3 should
1048
+ * use `getBlobUrlWithProgress`/`fetchBlob` instead, same as before.
1049
+ */
1050
+ async streamBlobToSink(
1051
+ blobId: string,
1052
+ onChunk: (chunk: Uint8Array, offset: number) => void | Promise<void>,
1053
+ onProgress: (received: number, total: number) => void,
1054
+ blake3?: string,
1055
+ totalBytes?: number,
1056
+ mimeType?: string,
1057
+ ): Promise<{ contentType: string; totalBytes: number }> {
1058
+ if (!blake3) {
1059
+ throw new TransportError(
1060
+ `streamBlobToSink requires a blake3 hash for verified streaming (blob ${blobId})`,
1061
+ );
1062
+ }
1063
+ const streamingFn =
1064
+ this.node.download_verified_streaming_with_ensure ?? this.node.download_verified_streaming;
1065
+ if (!streamingFn) {
1066
+ throw new TransportError(
1067
+ `streamBlobToSink: node has no streaming download method (blob ${blobId})`,
1068
+ );
1069
+ }
1070
+
1071
+ let totalReceived = 0;
1072
+ // chunk delivery is fire-and-forget from the wasm side (see
1073
+ // download_verified_streaming_with_ensure's own doc comment) - queue
1074
+ // each onChunk call after the previous one finishes so a slow sink
1075
+ // (e.g. an OPFS write) applies real backpressure instead of racing
1076
+ // ahead of disk writes.
1077
+ let writeChain: Promise<void> = Promise.resolve();
1078
+
1079
+ await streamingFn.call(
1080
+ this.node,
1081
+ this.peerAddr,
1082
+ blake3,
1083
+ totalBytes ?? 0,
1084
+ (chunk: Uint8Array, offset: number) => {
1085
+ // chunk is a wasm-owned Uint8Array view - copy to detach before
1086
+ // it's handed off across the write chain / a later tick.
1087
+ const owned = new Uint8Array(chunk.length);
1088
+ owned.set(chunk);
1089
+ totalReceived += owned.length;
1090
+ writeChain = writeChain.then(() => onChunk(owned, offset));
1091
+ if (!totalBytes || totalBytes <= 0) {
1092
+ onProgress(totalReceived, totalReceived);
1093
+ }
1094
+ },
1095
+ (fraction: number) => {
1096
+ if (totalBytes && totalBytes > 0) {
1097
+ const received = Math.min(totalBytes, Math.floor(fraction * totalBytes));
1098
+ onProgress(received, totalBytes);
1099
+ }
1100
+ },
1101
+ );
1102
+ await writeChain;
1103
+
1104
+ return { contentType: mimeType ?? "application/octet-stream", totalBytes: totalReceived };
1105
+ }
1106
+
978
1107
  /**
979
1108
  * revoke a blob URL to free memory
980
1109
  */
@@ -34,12 +34,17 @@ export const routes = {
34
34
  },
35
35
  app: {
36
36
  health_check: { method: 'GET', path: '/health', req: null, resp: s.HealthResponseSchema, auth: { type: 'public' } as const },
37
+ radio_clear_requests: { method: 'POST', path: '/api/radio/requests/clear', req: s.RadioStationRequestsRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'authenticated' } as const },
37
38
  radio_info: { method: 'GET', path: '/api/radio/info', req: null, resp: s.RadioInfoResponseSchema, auth: { type: 'public' } as const },
39
+ radio_list_requests: { method: 'POST', path: '/api/radio/requests/list', req: s.RadioStationRequestsRequestSchema, resp: s.RadioRequestsListResponseSchema, auth: { type: 'authenticated' } as const },
38
40
  radio_public_blob: { method: 'GET', path: '/api/radio/stations/{station_id}/blobs/{blob_id}', req: null, resp: null, auth: { type: 'public' } as const },
39
41
  radio_public_blob_data: { method: 'GET', path: '/api/radio/stations/{station_id}/blobs/{blob_id}/data', req: null, resp: null, auth: { type: 'public' } as const },
40
42
  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
43
  radio_public_timeline: { method: 'GET', path: '/api/radio/stations/{station_id}/timeline', req: null, resp: s.PublicTimelineManifestSchema, auth: { type: 'public' } as const },
44
+ radio_remove_request: { method: 'POST', path: '/api/radio/requests/remove', req: s.RemoveRadioRequestRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'authenticated' } as const },
42
45
  radio_stations: { method: 'GET', path: '/api/radio/stations', req: null, resp: s.RadioStationsResponseSchema, auth: { type: 'public' } as const },
46
+ radio_stations_full: { method: 'GET', path: '/api/radio/stations/full', req: null, resp: s.RadioStationsResponseSchema, auth: { type: 'authenticated' } as const },
47
+ radio_submit_request: { method: 'POST', path: '/api/radio/requests/submit', req: s.SubmitRadioRequestRequestSchema, resp: s.EmptyResponseSchema, auth: { type: 'authenticated' } as const },
43
48
  server_info: { method: 'GET', path: '/api/hello', req: null, resp: s.ServerInfoResponseSchema, auth: { type: 'public' } as const },
44
49
  },
45
50
  auth: {
@@ -252,6 +257,7 @@ export const routes = {
252
257
  import_video_paths: { method: 'POST', path: '/api/upload/video-paths', req: null, resp: s.VideoImportResponseSchema, auth: { type: 'role', role: 'member' } as const },
253
258
  list_pending_video_import_review: { method: 'POST', path: '/api/video/import/pending', req: s.ListPendingVideoReviewRequestSchema, resp: s.PendingVideoReviewSessionSchema.array(), auth: { type: 'authenticated' } as const },
254
259
  list_playback_progress: { method: 'POST', path: '/api/video/progress/list', req: s.ListPlaybackProgressRequestSchema, resp: s.PlaybackProgressSchema.array(), auth: { type: 'authenticated' } as const },
260
+ list_video_extras: { method: 'POST', path: '/api/video/videos/list-extras', req: s.ListVideoExtrasRequestSchema, resp: s.VideoSchema.array(), auth: { type: 'authenticated' } as const },
255
261
  list_video_seasons: { method: 'POST', path: '/api/video/seasons/list', req: s.ListVideoSeasonsRequestSchema, resp: s.VideoSeasonSchema.array(), auth: { type: 'authenticated' } as const },
256
262
  list_video_series: { method: 'POST', path: '/api/video/series/list', req: s.ListVideoSeriesRequestSchema, resp: s.VideoSeriesSchema.array(), auth: { type: 'authenticated' } as const },
257
263
  list_videos_by_season: { method: 'POST', path: '/api/video/videos/list-by-season', req: s.ListVideosBySeasonRequestSchema, resp: s.VideoSchema.array(), auth: { type: 'authenticated' } as const },
@@ -1508,7 +1508,9 @@ 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(),
1513
+ accepts_requests: z.boolean().nullish()
1512
1514
  });
1513
1515
  export type CreateStationRequest = z.infer<typeof CreateStationRequestSchema>;
1514
1516
 
@@ -1545,6 +1547,7 @@ export const CreateVideoRequestSchema = z.object({
1545
1547
  title: z.string(),
1546
1548
  description: z.string().nullish(),
1547
1549
  media_blob_id: z.string(),
1550
+ parent_video_id: z.string().nullish(),
1548
1551
  poster_blob_id: z.string().nullish(),
1549
1552
  duration_seconds: z.number().nullish(),
1550
1553
  release_date: z.string().nullish(),
@@ -2394,6 +2397,8 @@ export const FavoriteItemSchema = z.union([z.intersection(z.object({
2394
2397
  title: z.string(),
2395
2398
  description: z.string().nullish(),
2396
2399
  media_blob_id: z.string(),
2400
+ blake3: z.string().nullish(),
2401
+ parent_video_id: z.string().nullish(),
2397
2402
  poster_blob_id: z.string().nullish(),
2398
2403
  duration_seconds: z.number().nullish(),
2399
2404
  release_date: z.string().nullish(),
@@ -3701,6 +3706,8 @@ export const ListFavoritesResponseSchema = z.object({
3701
3706
  title: z.string(),
3702
3707
  description: z.string().nullish(),
3703
3708
  media_blob_id: z.string(),
3709
+ blake3: z.string().nullish(),
3710
+ parent_video_id: z.string().nullish(),
3704
3711
  poster_blob_id: z.string().nullish(),
3705
3712
  duration_seconds: z.number().nullish(),
3706
3713
  release_date: z.string().nullish(),
@@ -3896,6 +3903,11 @@ export const ListTaxonsByKindRequestSchema = z.object({
3896
3903
  });
3897
3904
  export type ListTaxonsByKindRequest = z.infer<typeof ListTaxonsByKindRequestSchema>;
3898
3905
 
3906
+ export const ListVideoExtrasRequestSchema = z.object({
3907
+ parent_video_id: z.string()
3908
+ });
3909
+ export type ListVideoExtrasRequest = z.infer<typeof ListVideoExtrasRequestSchema>;
3910
+
3899
3911
  export const ListVideoSeasonsRequestSchema = z.object({
3900
3912
  series_id: z.string().nullish()
3901
3913
  });
@@ -5190,6 +5202,7 @@ export const PublicAssetRefSchema = z.object({
5190
5202
  export type PublicAssetRef = z.infer<typeof PublicAssetRefSchema>;
5191
5203
 
5192
5204
  export const PublicNowPlayingSchema = z.object({
5205
+ kind: z.string(),
5193
5206
  song_id: z.string(),
5194
5207
  title: z.string(),
5195
5208
  artist: z.string().nullish(),
@@ -5210,7 +5223,10 @@ export const PublicStationSchema = z.object({
5210
5223
  listener_count: z.number(),
5211
5224
  is_default: z.boolean(),
5212
5225
  is_public: z.boolean(),
5226
+ is_running: z.boolean(),
5227
+ accepts_requests: z.boolean(),
5213
5228
  now_playing: z.object({
5229
+ kind: z.string(),
5214
5230
  song_id: z.string(),
5215
5231
  title: z.string(),
5216
5232
  artist: z.string().nullish(),
@@ -5391,7 +5407,8 @@ export type QueryVideosRequest = z.infer<typeof QueryVideosRequestSchema>;
5391
5407
  export const RadioBumperSchema = z.object({
5392
5408
  id: z.string(),
5393
5409
  station_id: z.string(),
5394
- song_id: z.string(),
5410
+ song_id: z.string().nullish(),
5411
+ video_id: z.string().nullish(),
5395
5412
  label: z.string(),
5396
5413
  weight: z.number(),
5397
5414
  created_at: z.number()
@@ -5400,7 +5417,8 @@ export type RadioBumper = z.infer<typeof RadioBumperSchema>;
5400
5417
 
5401
5418
  export const RadioBumpersAddRequestSchema = z.object({
5402
5419
  station_id: z.string(),
5403
- song_id: z.string(),
5420
+ song_id: z.string().nullish(),
5421
+ video_id: z.string().nullish(),
5404
5422
  label: z.string(),
5405
5423
  weight: z.number().nullish()
5406
5424
  });
@@ -5424,8 +5442,12 @@ export type RadioBumpersSetFrequencyRequest = z.infer<typeof RadioBumpersSetFreq
5424
5442
 
5425
5443
  export const RadioConfigPayloadSchema = z.object({
5426
5444
  enabled: z.boolean(),
5427
- encode_args: z.string(),
5428
- ffmpeg_available: z.boolean()
5445
+ encode_args: z.string().nullish(),
5446
+ video_encode_args: z.string().nullish(),
5447
+ video_codec: z.string().nullish(),
5448
+ ffmpeg_available: z.boolean(),
5449
+ max_concurrent_audio_streams: z.number(),
5450
+ max_concurrent_video_streams: z.number()
5429
5451
  });
5430
5452
  export type RadioConfigPayload = z.infer<typeof RadioConfigPayloadSchema>;
5431
5453
 
@@ -5451,7 +5473,10 @@ export const RadioInfoResponseSchema = z.object({
5451
5473
  listener_count: z.number(),
5452
5474
  is_default: z.boolean(),
5453
5475
  is_public: z.boolean(),
5476
+ is_running: z.boolean(),
5477
+ accepts_requests: z.boolean(),
5454
5478
  now_playing: z.object({
5479
+ kind: z.string(),
5455
5480
  song_id: z.string(),
5456
5481
  title: z.string(),
5457
5482
  artist: z.string().nullish(),
@@ -5468,6 +5493,34 @@ export const RadioInfoResponseSchema = z.object({
5468
5493
  });
5469
5494
  export type RadioInfoResponse = z.infer<typeof RadioInfoResponseSchema>;
5470
5495
 
5496
+ export const RadioQueuedRequestInfoSchema = z.object({
5497
+ id: z.string(),
5498
+ kind: z.string(),
5499
+ item_id: z.string(),
5500
+ requested_by: z.string(),
5501
+ title: z.string(),
5502
+ artist: z.string().nullish(),
5503
+ album: z.string().nullish(),
5504
+ duration_ms: z.number().nullish(),
5505
+ art_blob_id: z.string().nullish()
5506
+ });
5507
+ export type RadioQueuedRequestInfo = z.infer<typeof RadioQueuedRequestInfoSchema>;
5508
+
5509
+ export const RadioRequestsListResponseSchema = z.object({
5510
+ requests: z.array(z.object({
5511
+ id: z.string(),
5512
+ kind: z.string(),
5513
+ item_id: z.string(),
5514
+ requested_by: z.string(),
5515
+ title: z.string(),
5516
+ artist: z.string().nullish(),
5517
+ album: z.string().nullish(),
5518
+ duration_ms: z.number().nullish(),
5519
+ art_blob_id: z.string().nullish()
5520
+ }))
5521
+ });
5522
+ export type RadioRequestsListResponse = z.infer<typeof RadioRequestsListResponseSchema>;
5523
+
5471
5524
  export const RadioSeedSuggestRequestSchema = z.object({
5472
5525
  kind: z.string(),
5473
5526
  query: z.string(),
@@ -5493,6 +5546,9 @@ export const RadioStationSchema = z.object({
5493
5546
  codec: z.string(),
5494
5547
  play_mode: z.string(),
5495
5548
  timeline_only_mode: z.number(),
5549
+ content_mode: z.string(),
5550
+ bumper_frequency_seconds: z.number().nullish(),
5551
+ accepts_requests: z.number(),
5496
5552
  created_at: z.number(),
5497
5553
  updated_at: z.number()
5498
5554
  });
@@ -5503,6 +5559,11 @@ export const RadioStationByStationIdRequestSchema = z.object({
5503
5559
  });
5504
5560
  export type RadioStationByStationIdRequest = z.infer<typeof RadioStationByStationIdRequestSchema>;
5505
5561
 
5562
+ export const RadioStationRequestsRequestSchema = z.object({
5563
+ station_id: z.string()
5564
+ });
5565
+ export type RadioStationRequestsRequest = z.infer<typeof RadioStationRequestsRequestSchema>;
5566
+
5506
5567
  export const RadioStationSupervisorStatusSchema = z.object({
5507
5568
  station_id: z.string(),
5508
5569
  name: z.string(),
@@ -5530,7 +5591,10 @@ export const RadioStationsResponseSchema = z.object({
5530
5591
  listener_count: z.number(),
5531
5592
  is_default: z.boolean(),
5532
5593
  is_public: z.boolean(),
5594
+ is_running: z.boolean(),
5595
+ accepts_requests: z.boolean(),
5533
5596
  now_playing: z.object({
5597
+ kind: z.string(),
5534
5598
  song_id: z.string(),
5535
5599
  title: z.string(),
5536
5600
  artist: z.string().nullish(),
@@ -5687,6 +5751,8 @@ export const RecentlyAddedVideosResponseSchema = z.object({
5687
5751
  title: z.string(),
5688
5752
  description: z.string().nullish(),
5689
5753
  media_blob_id: z.string(),
5754
+ blake3: z.string().nullish(),
5755
+ parent_video_id: z.string().nullish(),
5690
5756
  poster_blob_id: z.string().nullish(),
5691
5757
  duration_seconds: z.number().nullish(),
5692
5758
  release_date: z.string().nullish(),
@@ -5900,6 +5966,12 @@ export const RemovePlaylistThumbnailRequestSchema = z.object({
5900
5966
  });
5901
5967
  export type RemovePlaylistThumbnailRequest = z.infer<typeof RemovePlaylistThumbnailRequestSchema>;
5902
5968
 
5969
+ export const RemoveRadioRequestRequestSchema = z.object({
5970
+ station_id: z.string(),
5971
+ request_id: z.string()
5972
+ });
5973
+ export type RemoveRadioRequestRequest = z.infer<typeof RemoveRadioRequestRequestSchema>;
5974
+
5903
5975
  export const RemoveRatingRequestSchema = z.object({
5904
5976
  user_id: z.string().nullish(),
5905
5977
  target_type: z.union([z.literal("song"), z.literal("artist"), z.literal("album"), z.literal("video")]),
@@ -6161,6 +6233,8 @@ export const SeasonWithVideosSchema = z.object({
6161
6233
  title: z.string(),
6162
6234
  description: z.string().nullish(),
6163
6235
  media_blob_id: z.string(),
6236
+ blake3: z.string().nullish(),
6237
+ parent_video_id: z.string().nullish(),
6164
6238
  poster_blob_id: z.string().nullish(),
6165
6239
  duration_seconds: z.number().nullish(),
6166
6240
  release_date: z.string().nullish(),
@@ -6220,6 +6294,8 @@ export const SeriesDetailSchema = z.object({
6220
6294
  title: z.string(),
6221
6295
  description: z.string().nullish(),
6222
6296
  media_blob_id: z.string(),
6297
+ blake3: z.string().nullish(),
6298
+ parent_video_id: z.string().nullish(),
6223
6299
  poster_blob_id: z.string().nullish(),
6224
6300
  duration_seconds: z.number().nullish(),
6225
6301
  release_date: z.string().nullish(),
@@ -6246,6 +6322,8 @@ export const SeriesDetailSchema = z.object({
6246
6322
  title: z.string(),
6247
6323
  description: z.string().nullish(),
6248
6324
  media_blob_id: z.string(),
6325
+ blake3: z.string().nullish(),
6326
+ parent_video_id: z.string().nullish(),
6249
6327
  poster_blob_id: z.string().nullish(),
6250
6328
  duration_seconds: z.number().nullish(),
6251
6329
  release_date: z.string().nullish(),
@@ -6809,6 +6887,13 @@ export const StationFilterSchema = z.object({
6809
6887
  });
6810
6888
  export type StationFilter = z.infer<typeof StationFilterSchema>;
6811
6889
 
6890
+ export const SubmitRadioRequestRequestSchema = z.object({
6891
+ station_id: z.string(),
6892
+ kind: z.string(),
6893
+ item_id: z.string()
6894
+ });
6895
+ export type SubmitRadioRequestRequest = z.infer<typeof SubmitRadioRequestRequestSchema>;
6896
+
6812
6897
  export const SuggestionSchema = z.object({
6813
6898
  value: z.string(),
6814
6899
  display: z.string(),
@@ -7007,6 +7092,7 @@ export const SyncVideoByBlake3RequestSchema = z.object({
7007
7092
  series_description: z.string().nullish(),
7008
7093
  season_number: z.number().nullish(),
7009
7094
  season_title: z.string().nullish(),
7095
+ parent_blake3: z.string().nullish(),
7010
7096
  video_images: z.array(z.object({
7011
7097
  content_sha256: z.string(),
7012
7098
  data_base64: z.string().nullish(),
@@ -7303,6 +7389,8 @@ export const UnassignedVideosResponseSchema = z.object({
7303
7389
  title: z.string(),
7304
7390
  description: z.string().nullish(),
7305
7391
  media_blob_id: z.string(),
7392
+ blake3: z.string().nullish(),
7393
+ parent_video_id: z.string().nullish(),
7306
7394
  poster_blob_id: z.string().nullish(),
7307
7395
  duration_seconds: z.number().nullish(),
7308
7396
  release_date: z.string().nullish(),
@@ -7631,7 +7719,9 @@ export const UpdateStationRequestSchema = z.object({
7631
7719
  encode_args: z.string().nullish(),
7632
7720
  codec: z.string().nullish(),
7633
7721
  play_mode: z.string().nullish(),
7634
- timeline_only_mode: z.boolean().nullish()
7722
+ timeline_only_mode: z.boolean().nullish(),
7723
+ content_mode: z.string().nullish(),
7724
+ accepts_requests: z.boolean().nullish()
7635
7725
  });
7636
7726
  export type UpdateStationRequest = z.infer<typeof UpdateStationRequestSchema>;
7637
7727
 
@@ -7648,12 +7738,14 @@ export const UpdateVideoRequestSchema = z.object({
7648
7738
  content_type: z.string().nullish(),
7649
7739
  title: z.string().nullish(),
7650
7740
  description: z.string().nullish(),
7741
+ parent_video_id: z.string().nullish(),
7651
7742
  poster_blob_id: z.string().nullish(),
7652
7743
  duration_seconds: z.number().nullish(),
7653
7744
  release_date: z.string().nullish(),
7654
7745
  updated_by: z.string().nullish(),
7655
7746
  clear_series_id: z.boolean(),
7656
- clear_season_id: z.boolean()
7747
+ clear_season_id: z.boolean(),
7748
+ clear_parent_video_id: z.boolean()
7657
7749
  });
7658
7750
  export type UpdateVideoRequest = z.infer<typeof UpdateVideoRequestSchema>;
7659
7751
 
@@ -7687,8 +7779,10 @@ export const UpdateVideosRequestSchema = z.object({
7687
7779
  duration_seconds: z.number().nullish(),
7688
7780
  release_date: z.string().nullish(),
7689
7781
  updated_by: z.string().nullish(),
7782
+ parent_video_id: z.string().nullish(),
7690
7783
  clear_series_id: z.boolean(),
7691
- clear_season_id: z.boolean()
7784
+ clear_season_id: z.boolean(),
7785
+ clear_parent_video_id: z.boolean()
7692
7786
  });
7693
7787
  export type UpdateVideosRequest = z.infer<typeof UpdateVideosRequestSchema>;
7694
7788
 
@@ -7764,6 +7858,8 @@ export const VideoSchema = z.object({
7764
7858
  title: z.string(),
7765
7859
  description: z.string().nullish(),
7766
7860
  media_blob_id: z.string(),
7861
+ blake3: z.string().nullish(),
7862
+ parent_video_id: z.string().nullish(),
7767
7863
  poster_blob_id: z.string().nullish(),
7768
7864
  duration_seconds: z.number().nullish(),
7769
7865
  release_date: z.string().nullish(),
@@ -7923,6 +8019,8 @@ export const VideoWithMetadataSchema = z.object({
7923
8019
  title: z.string(),
7924
8020
  description: z.string().nullish(),
7925
8021
  media_blob_id: z.string(),
8022
+ blake3: z.string().nullish(),
8023
+ parent_video_id: z.string().nullish(),
7926
8024
  poster_blob_id: z.string().nullish(),
7927
8025
  duration_seconds: z.number().nullish(),
7928
8026
  release_date: z.string().nullish(),
@@ -7971,6 +8069,8 @@ export const VideosByValueResponseSchema = z.object({
7971
8069
  title: z.string(),
7972
8070
  description: z.string().nullish(),
7973
8071
  media_blob_id: z.string(),
8072
+ blake3: z.string().nullish(),
8073
+ parent_video_id: z.string().nullish(),
7974
8074
  poster_blob_id: z.string().nullish(),
7975
8075
  duration_seconds: z.number().nullish(),
7976
8076
  release_date: z.string().nullish(),
@@ -8001,6 +8101,8 @@ export const VideosQueryResultSchema = z.object({
8001
8101
  title: z.string(),
8002
8102
  description: z.string().nullish(),
8003
8103
  media_blob_id: z.string(),
8104
+ blake3: z.string().nullish(),
8105
+ parent_video_id: z.string().nullish(),
8004
8106
  poster_blob_id: z.string().nullish(),
8005
8107
  duration_seconds: z.number().nullish(),
8006
8108
  release_date: z.string().nullish(),
@@ -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,84 @@ 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
+
66
+ // submit a member request to an `accepts_requests` station's queue -
67
+ // any authenticated caller may call this (not admin-only, unlike
68
+ // radio_filters_add's "add to station").
69
+ radioSubmitRequest: (params: {
70
+ station_id: string;
71
+ kind: "song" | "video";
72
+ item_id: string;
73
+ }) => {
74
+ return call(
75
+ "app",
76
+ "radio_submit_request",
77
+ routes.app.radio_submit_request.resp,
78
+ routes.app.radio_submit_request.req,
79
+ routes.app.radio_submit_request.method,
80
+ routes.app.radio_submit_request.path,
81
+ params,
82
+ );
83
+ },
84
+
85
+ // list every currently-queued request for a station (the "queue" tab)
86
+ radioListRequests: (params: { station_id: string }) => {
87
+ return call(
88
+ "app",
89
+ "radio_list_requests",
90
+ routes.app.radio_list_requests.resp,
91
+ routes.app.radio_list_requests.req,
92
+ routes.app.radio_list_requests.method,
93
+ routes.app.radio_list_requests.path,
94
+ params,
95
+ );
96
+ },
97
+
98
+ // remove one queued request - any authenticated caller may call this
99
+ // (member-submitted content, not admin-owned configuration).
100
+ radioRemoveRequest: (params: { station_id: string; request_id: string }) => {
101
+ return call(
102
+ "app",
103
+ "radio_remove_request",
104
+ routes.app.radio_remove_request.resp,
105
+ routes.app.radio_remove_request.req,
106
+ routes.app.radio_remove_request.method,
107
+ routes.app.radio_remove_request.path,
108
+ params,
109
+ );
110
+ },
111
+
112
+ // clear every queued request for a station at once
113
+ radioClearRequests: (params: { station_id: string }) => {
114
+ return call(
115
+ "app",
116
+ "radio_clear_requests",
117
+ routes.app.radio_clear_requests.resp,
118
+ routes.app.radio_clear_requests.req,
119
+ routes.app.radio_clear_requests.method,
120
+ routes.app.radio_clear_requests.path,
121
+ params,
122
+ );
123
+ },
124
+
48
125
  // get blob metadata for a station blob
49
126
  radioPublicBlob: (params: { station_id: string; blob_id: string }) => {
50
127
  return call(
51
- "app", "radio_public_blob",
128
+ "app",
129
+ "radio_public_blob",
52
130
  routes.app.radio_public_blob.resp,
53
131
  routes.app.radio_public_blob.req,
54
132
  routes.app.radio_public_blob.method,
@@ -60,7 +138,8 @@ export function createAppMethods(call: CallFn) {
60
138
  // stream the binary data for a station blob
61
139
  radioPublicBlobData: (params: { station_id: string; blob_id: string }) => {
62
140
  return call(
63
- "app", "radio_public_blob_data",
141
+ "app",
142
+ "radio_public_blob_data",
64
143
  routes.app.radio_public_blob_data.resp,
65
144
  routes.app.radio_public_blob_data.req,
66
145
  routes.app.radio_public_blob_data.method,
@@ -72,7 +151,8 @@ export function createAppMethods(call: CallFn) {
72
151
  // get a thumbnail for a station blob at a given size
73
152
  radioPublicBlobThumbnail: (params: { station_id: string; blob_id: string; size: string }) => {
74
153
  return call(
75
- "app", "radio_public_blob_thumbnail",
154
+ "app",
155
+ "radio_public_blob_thumbnail",
76
156
  routes.app.radio_public_blob_thumbnail.resp,
77
157
  routes.app.radio_public_blob_thumbnail.req,
78
158
  routes.app.radio_public_blob_thumbnail.method,
@@ -84,7 +164,8 @@ export function createAppMethods(call: CallFn) {
84
164
  // get the public timeline manifest for a station
85
165
  radioPublicTimeline: (params: { station_id: string }) => {
86
166
  return call(
87
- "app", "radio_public_timeline",
167
+ "app",
168
+ "radio_public_timeline",
88
169
  routes.app.radio_public_timeline.resp,
89
170
  routes.app.radio_public_timeline.req,
90
171
  routes.app.radio_public_timeline.method,
@@ -107,6 +107,18 @@ export function createVideoMethods(call: CallFn) {
107
107
  );
108
108
  },
109
109
 
110
+ listVideoExtras: (params: s.ListVideoExtrasRequest) => {
111
+ return call(
112
+ "video",
113
+ "list_video_extras",
114
+ routes.video.list_video_extras.resp,
115
+ routes.video.list_video_extras.req,
116
+ routes.video.list_video_extras.method,
117
+ routes.video.list_video_extras.path,
118
+ params,
119
+ );
120
+ },
121
+
110
122
  updateVideos: (params: Partial<s.UpdateVideosRequest> & { video_ids: string[] }) => {
111
123
  return call(
112
124
  "video",
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export type {
39
39
  BlobProgressCallback,
40
40
  RadioHandleLike,
41
41
  BiStreamLike,
42
+ ActiveTransferLike,
42
43
  } from "./WasmTransport.js";
43
44
  export type { Transport, TransportResponse, BlobData } from "./transport.js";
44
45
 
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