@freqhole/api-client 0.3.7 → 0.3.9

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.7",
3
+ "version": "0.3.9",
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
 
@@ -34,13 +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 },
43
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 },
44
48
  server_info: { method: 'GET', path: '/api/hello', req: null, resp: s.ServerInfoResponseSchema, auth: { type: 'public' } as const },
45
49
  },
46
50
  auth: {
@@ -253,6 +257,7 @@ export const routes = {
253
257
  import_video_paths: { method: 'POST', path: '/api/upload/video-paths', req: null, resp: s.VideoImportResponseSchema, auth: { type: 'role', role: 'member' } as const },
254
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 },
255
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 },
256
261
  list_video_seasons: { method: 'POST', path: '/api/video/seasons/list', req: s.ListVideoSeasonsRequestSchema, resp: s.VideoSeasonSchema.array(), auth: { type: 'authenticated' } as const },
257
262
  list_video_series: { method: 'POST', path: '/api/video/series/list', req: s.ListVideoSeriesRequestSchema, resp: s.VideoSeriesSchema.array(), auth: { type: 'authenticated' } as const },
258
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 },
@@ -1509,7 +1509,8 @@ export const CreateStationRequestSchema = z.object({
1509
1509
  codec: z.string().nullish(),
1510
1510
  play_mode: z.string().nullish(),
1511
1511
  timeline_only_mode: z.boolean().nullish(),
1512
- content_mode: z.string().nullish()
1512
+ content_mode: z.string().nullish(),
1513
+ accepts_requests: z.boolean().nullish()
1513
1514
  });
1514
1515
  export type CreateStationRequest = z.infer<typeof CreateStationRequestSchema>;
1515
1516
 
@@ -1546,6 +1547,7 @@ export const CreateVideoRequestSchema = z.object({
1546
1547
  title: z.string(),
1547
1548
  description: z.string().nullish(),
1548
1549
  media_blob_id: z.string(),
1550
+ parent_video_id: z.string().nullish(),
1549
1551
  poster_blob_id: z.string().nullish(),
1550
1552
  duration_seconds: z.number().nullish(),
1551
1553
  release_date: z.string().nullish(),
@@ -2395,6 +2397,8 @@ export const FavoriteItemSchema = z.union([z.intersection(z.object({
2395
2397
  title: z.string(),
2396
2398
  description: z.string().nullish(),
2397
2399
  media_blob_id: z.string(),
2400
+ blake3: z.string().nullish(),
2401
+ parent_video_id: z.string().nullish(),
2398
2402
  poster_blob_id: z.string().nullish(),
2399
2403
  duration_seconds: z.number().nullish(),
2400
2404
  release_date: z.string().nullish(),
@@ -3702,6 +3706,8 @@ export const ListFavoritesResponseSchema = z.object({
3702
3706
  title: z.string(),
3703
3707
  description: z.string().nullish(),
3704
3708
  media_blob_id: z.string(),
3709
+ blake3: z.string().nullish(),
3710
+ parent_video_id: z.string().nullish(),
3705
3711
  poster_blob_id: z.string().nullish(),
3706
3712
  duration_seconds: z.number().nullish(),
3707
3713
  release_date: z.string().nullish(),
@@ -3897,6 +3903,11 @@ export const ListTaxonsByKindRequestSchema = z.object({
3897
3903
  });
3898
3904
  export type ListTaxonsByKindRequest = z.infer<typeof ListTaxonsByKindRequestSchema>;
3899
3905
 
3906
+ export const ListVideoExtrasRequestSchema = z.object({
3907
+ parent_video_id: z.string()
3908
+ });
3909
+ export type ListVideoExtrasRequest = z.infer<typeof ListVideoExtrasRequestSchema>;
3910
+
3900
3911
  export const ListVideoSeasonsRequestSchema = z.object({
3901
3912
  series_id: z.string().nullish()
3902
3913
  });
@@ -5213,6 +5224,7 @@ export const PublicStationSchema = z.object({
5213
5224
  is_default: z.boolean(),
5214
5225
  is_public: z.boolean(),
5215
5226
  is_running: z.boolean(),
5227
+ accepts_requests: z.boolean(),
5216
5228
  now_playing: z.object({
5217
5229
  kind: z.string(),
5218
5230
  song_id: z.string(),
@@ -5462,6 +5474,7 @@ export const RadioInfoResponseSchema = z.object({
5462
5474
  is_default: z.boolean(),
5463
5475
  is_public: z.boolean(),
5464
5476
  is_running: z.boolean(),
5477
+ accepts_requests: z.boolean(),
5465
5478
  now_playing: z.object({
5466
5479
  kind: z.string(),
5467
5480
  song_id: z.string(),
@@ -5480,6 +5493,34 @@ export const RadioInfoResponseSchema = z.object({
5480
5493
  });
5481
5494
  export type RadioInfoResponse = z.infer<typeof RadioInfoResponseSchema>;
5482
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
+
5483
5524
  export const RadioSeedSuggestRequestSchema = z.object({
5484
5525
  kind: z.string(),
5485
5526
  query: z.string(),
@@ -5507,6 +5548,7 @@ export const RadioStationSchema = z.object({
5507
5548
  timeline_only_mode: z.number(),
5508
5549
  content_mode: z.string(),
5509
5550
  bumper_frequency_seconds: z.number().nullish(),
5551
+ accepts_requests: z.number(),
5510
5552
  created_at: z.number(),
5511
5553
  updated_at: z.number()
5512
5554
  });
@@ -5517,6 +5559,11 @@ export const RadioStationByStationIdRequestSchema = z.object({
5517
5559
  });
5518
5560
  export type RadioStationByStationIdRequest = z.infer<typeof RadioStationByStationIdRequestSchema>;
5519
5561
 
5562
+ export const RadioStationRequestsRequestSchema = z.object({
5563
+ station_id: z.string()
5564
+ });
5565
+ export type RadioStationRequestsRequest = z.infer<typeof RadioStationRequestsRequestSchema>;
5566
+
5520
5567
  export const RadioStationSupervisorStatusSchema = z.object({
5521
5568
  station_id: z.string(),
5522
5569
  name: z.string(),
@@ -5545,6 +5592,7 @@ export const RadioStationsResponseSchema = z.object({
5545
5592
  is_default: z.boolean(),
5546
5593
  is_public: z.boolean(),
5547
5594
  is_running: z.boolean(),
5595
+ accepts_requests: z.boolean(),
5548
5596
  now_playing: z.object({
5549
5597
  kind: z.string(),
5550
5598
  song_id: z.string(),
@@ -5703,6 +5751,8 @@ export const RecentlyAddedVideosResponseSchema = z.object({
5703
5751
  title: z.string(),
5704
5752
  description: z.string().nullish(),
5705
5753
  media_blob_id: z.string(),
5754
+ blake3: z.string().nullish(),
5755
+ parent_video_id: z.string().nullish(),
5706
5756
  poster_blob_id: z.string().nullish(),
5707
5757
  duration_seconds: z.number().nullish(),
5708
5758
  release_date: z.string().nullish(),
@@ -5916,6 +5966,12 @@ export const RemovePlaylistThumbnailRequestSchema = z.object({
5916
5966
  });
5917
5967
  export type RemovePlaylistThumbnailRequest = z.infer<typeof RemovePlaylistThumbnailRequestSchema>;
5918
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
+
5919
5975
  export const RemoveRatingRequestSchema = z.object({
5920
5976
  user_id: z.string().nullish(),
5921
5977
  target_type: z.union([z.literal("song"), z.literal("artist"), z.literal("album"), z.literal("video")]),
@@ -6177,6 +6233,8 @@ export const SeasonWithVideosSchema = z.object({
6177
6233
  title: z.string(),
6178
6234
  description: z.string().nullish(),
6179
6235
  media_blob_id: z.string(),
6236
+ blake3: z.string().nullish(),
6237
+ parent_video_id: z.string().nullish(),
6180
6238
  poster_blob_id: z.string().nullish(),
6181
6239
  duration_seconds: z.number().nullish(),
6182
6240
  release_date: z.string().nullish(),
@@ -6236,6 +6294,8 @@ export const SeriesDetailSchema = z.object({
6236
6294
  title: z.string(),
6237
6295
  description: z.string().nullish(),
6238
6296
  media_blob_id: z.string(),
6297
+ blake3: z.string().nullish(),
6298
+ parent_video_id: z.string().nullish(),
6239
6299
  poster_blob_id: z.string().nullish(),
6240
6300
  duration_seconds: z.number().nullish(),
6241
6301
  release_date: z.string().nullish(),
@@ -6262,6 +6322,8 @@ export const SeriesDetailSchema = z.object({
6262
6322
  title: z.string(),
6263
6323
  description: z.string().nullish(),
6264
6324
  media_blob_id: z.string(),
6325
+ blake3: z.string().nullish(),
6326
+ parent_video_id: z.string().nullish(),
6265
6327
  poster_blob_id: z.string().nullish(),
6266
6328
  duration_seconds: z.number().nullish(),
6267
6329
  release_date: z.string().nullish(),
@@ -6825,6 +6887,13 @@ export const StationFilterSchema = z.object({
6825
6887
  });
6826
6888
  export type StationFilter = z.infer<typeof StationFilterSchema>;
6827
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
+
6828
6897
  export const SuggestionSchema = z.object({
6829
6898
  value: z.string(),
6830
6899
  display: z.string(),
@@ -7023,6 +7092,7 @@ export const SyncVideoByBlake3RequestSchema = z.object({
7023
7092
  series_description: z.string().nullish(),
7024
7093
  season_number: z.number().nullish(),
7025
7094
  season_title: z.string().nullish(),
7095
+ parent_blake3: z.string().nullish(),
7026
7096
  video_images: z.array(z.object({
7027
7097
  content_sha256: z.string(),
7028
7098
  data_base64: z.string().nullish(),
@@ -7319,6 +7389,8 @@ export const UnassignedVideosResponseSchema = z.object({
7319
7389
  title: z.string(),
7320
7390
  description: z.string().nullish(),
7321
7391
  media_blob_id: z.string(),
7392
+ blake3: z.string().nullish(),
7393
+ parent_video_id: z.string().nullish(),
7322
7394
  poster_blob_id: z.string().nullish(),
7323
7395
  duration_seconds: z.number().nullish(),
7324
7396
  release_date: z.string().nullish(),
@@ -7648,7 +7720,8 @@ export const UpdateStationRequestSchema = z.object({
7648
7720
  codec: z.string().nullish(),
7649
7721
  play_mode: z.string().nullish(),
7650
7722
  timeline_only_mode: z.boolean().nullish(),
7651
- content_mode: z.string().nullish()
7723
+ content_mode: z.string().nullish(),
7724
+ accepts_requests: z.boolean().nullish()
7652
7725
  });
7653
7726
  export type UpdateStationRequest = z.infer<typeof UpdateStationRequestSchema>;
7654
7727
 
@@ -7665,12 +7738,14 @@ export const UpdateVideoRequestSchema = z.object({
7665
7738
  content_type: z.string().nullish(),
7666
7739
  title: z.string().nullish(),
7667
7740
  description: z.string().nullish(),
7741
+ parent_video_id: z.string().nullish(),
7668
7742
  poster_blob_id: z.string().nullish(),
7669
7743
  duration_seconds: z.number().nullish(),
7670
7744
  release_date: z.string().nullish(),
7671
7745
  updated_by: z.string().nullish(),
7672
7746
  clear_series_id: z.boolean(),
7673
- clear_season_id: z.boolean()
7747
+ clear_season_id: z.boolean(),
7748
+ clear_parent_video_id: z.boolean()
7674
7749
  });
7675
7750
  export type UpdateVideoRequest = z.infer<typeof UpdateVideoRequestSchema>;
7676
7751
 
@@ -7704,8 +7779,10 @@ export const UpdateVideosRequestSchema = z.object({
7704
7779
  duration_seconds: z.number().nullish(),
7705
7780
  release_date: z.string().nullish(),
7706
7781
  updated_by: z.string().nullish(),
7782
+ parent_video_id: z.string().nullish(),
7707
7783
  clear_series_id: z.boolean(),
7708
- clear_season_id: z.boolean()
7784
+ clear_season_id: z.boolean(),
7785
+ clear_parent_video_id: z.boolean()
7709
7786
  });
7710
7787
  export type UpdateVideosRequest = z.infer<typeof UpdateVideosRequestSchema>;
7711
7788
 
@@ -7781,6 +7858,8 @@ export const VideoSchema = z.object({
7781
7858
  title: z.string(),
7782
7859
  description: z.string().nullish(),
7783
7860
  media_blob_id: z.string(),
7861
+ blake3: z.string().nullish(),
7862
+ parent_video_id: z.string().nullish(),
7784
7863
  poster_blob_id: z.string().nullish(),
7785
7864
  duration_seconds: z.number().nullish(),
7786
7865
  release_date: z.string().nullish(),
@@ -7940,6 +8019,8 @@ export const VideoWithMetadataSchema = z.object({
7940
8019
  title: z.string(),
7941
8020
  description: z.string().nullish(),
7942
8021
  media_blob_id: z.string(),
8022
+ blake3: z.string().nullish(),
8023
+ parent_video_id: z.string().nullish(),
7943
8024
  poster_blob_id: z.string().nullish(),
7944
8025
  duration_seconds: z.number().nullish(),
7945
8026
  release_date: z.string().nullish(),
@@ -7988,6 +8069,8 @@ export const VideosByValueResponseSchema = z.object({
7988
8069
  title: z.string(),
7989
8070
  description: z.string().nullish(),
7990
8071
  media_blob_id: z.string(),
8072
+ blake3: z.string().nullish(),
8073
+ parent_video_id: z.string().nullish(),
7991
8074
  poster_blob_id: z.string().nullish(),
7992
8075
  duration_seconds: z.number().nullish(),
7993
8076
  release_date: z.string().nullish(),
@@ -8018,6 +8101,8 @@ export const VideosQueryResultSchema = z.object({
8018
8101
  title: z.string(),
8019
8102
  description: z.string().nullish(),
8020
8103
  media_blob_id: z.string(),
8104
+ blake3: z.string().nullish(),
8105
+ parent_video_id: z.string().nullish(),
8021
8106
  poster_blob_id: z.string().nullish(),
8022
8107
  duration_seconds: z.number().nullish(),
8023
8108
  release_date: z.string().nullish(),
@@ -63,6 +63,65 @@ export function createAppMethods(call: CallFn) {
63
63
  );
64
64
  },
65
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
+
66
125
  // get blob metadata for a station blob
67
126
  radioPublicBlob: (params: { station_id: string; blob_id: string }) => {
68
127
  return call(
@@ -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