@freqhole/api-client 0.3.3 → 0.3.5

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.3",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,37 +4,16 @@
4
4
  // calls grimoire::api::dispatch directly via the api_call IPC command.
5
5
  // uses Tauri's asset protocol for blob/audio file access (no HTTP streaming).
6
6
 
7
- import type { Transport, TransportResponse, BlobData } from "./transport.js";
7
+ import type { Transport, TransportResponse, BlobData, UploadMetadata } from "./transport.js";
8
8
  import type { CloseReason, EventFilter, JobEvent, JobStateSnapshot } from "./codegen/schema.js";
9
+ import { bytesToBase64 } from "./base64.js";
9
10
 
10
11
  // tauri invoke function type
11
12
  type InvokeFn = (cmd: string, args?: unknown) => Promise<unknown>;
12
13
 
13
14
  // tauri invoke is dynamically imported to avoid bundling in browser builds
14
15
  let invoke: InvokeFn | null = null;
15
- let convertFileSrc: ((path: string) => string) | null = null;
16
-
17
- // webkitgtk (linux) can't play asset:// URLs in <audio> elements.
18
- // detect once at module level so we can use blob: URLs as a workaround.
19
- //
20
- // historically there was a second workaround here — an embedded http
21
- // loopback server (`media_server_info` ipc + `server::media_server`)
22
- // that served blobs via plain http. it has been removed in favor of
23
- // the rodio backend (see `client/spume/src/music/services/audio/`),
24
- // which bypasses the html `<audio>` element entirely on linux. when
25
- // rodio is *not* enabled and we're on linux, we fall back to the
26
- // blob: object url path below.
27
- const isLinuxWebKit = typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
28
-
29
- /**
30
- * blobs that must never be buffered into memory as a blob: URL, even on
31
- * linux: a local video can easily be multi-GB, and `fetch().arrayBuffer()`
32
- * on one hangs the whole app. these stream from asset:// instead, which
33
- * also gives `<video>` real range requests for seeking.
34
- */
35
- function isStreamableMime(mime?: string | null): boolean {
36
- return (mime ?? "").startsWith("video/");
37
- }
16
+ let convertFileSrc: ((path: string, protocol?: string) => string) | null = null;
38
17
 
39
18
  /**
40
19
  * initialize tauri invoke function
@@ -52,6 +31,48 @@ async function ensureInvoke(): Promise<InvokeFn> {
52
31
  }
53
32
  }
54
33
 
34
+ /**
35
+ * build a playable url for a local file path via the custom
36
+ * `freqhole-media` protocol (see `client/charnel/src-tauri/src/
37
+ * media_protocol.rs`) - used on every platform, not just android: tauri's
38
+ * built-in `asset` protocol caps every range response to ~1MB regardless
39
+ * of what was actually requested, which a webview's media pipeline
40
+ * doesn't reliably recover from for anything larger (a compressed file
41
+ * limps along for ~30s before stalling; an uncompressed one like a wav
42
+ * can stop in single-digit seconds). `freqhole-media` mirrors the
43
+ * built-in protocol's shape but removes that cap, and is already
44
+ * registered on every platform regardless of which protocol the client
45
+ * actually requests.
46
+ */
47
+ function mediaSrcFor(path: string): string {
48
+ if (!convertFileSrc) {
49
+ throw new Error("convertFileSrc not available");
50
+ }
51
+ return convertFileSrc(path, "freqhole-media");
52
+ }
53
+
54
+ /**
55
+ * tauri's `invoke()` rejects with the raw `String` from a `Result<T,
56
+ * String>` command - not an `Error` instance - so a plain
57
+ * `err instanceof Error ? err.message : "..."` check silently discards
58
+ * that string. this covers the string case too.
59
+ */
60
+ function ipcErrorMessage(err: unknown): string {
61
+ if (err instanceof Error) return err.message;
62
+ if (typeof err === "string" && err.trim()) return err;
63
+ return "IPC error";
64
+ }
65
+
66
+ /**
67
+ * public, self-contained version of `mediaSrcFor` for callers outside this
68
+ * file (spume's `localAudio.ts`/`localVideo.ts`) - ensures tauri's invoke/
69
+ * convertFileSrc/target_os are loaded before resolving.
70
+ */
71
+ export async function resolveCharnelMediaSrc(path: string): Promise<string> {
72
+ await ensureInvoke();
73
+ return mediaSrcFor(path);
74
+ }
75
+
55
76
  /**
56
77
  * response shape from api_call command (matches GrimoireResponse)
57
78
  */
@@ -74,8 +95,6 @@ export class CharnelLocalTransport implements Transport {
74
95
  private blobPathCache = new Map<string, { path: string; mime?: string }>();
75
96
  // cache object URLs for db-stored blobs (no local path)
76
97
  private blobObjectUrlCache = new Map<string, string>();
77
- // single audio blob URL for linux workaround (revoke-on-replace to avoid memory leak)
78
- private audioBlobUrl: { blobId: string; url: string } | null = null;
79
98
 
80
99
  constructor(_baseUrl: string) {
81
100
  // baseUrl no longer needed - all requests go through IPC.
@@ -149,18 +168,22 @@ export class CharnelLocalTransport implements Transport {
149
168
  console.info(
150
169
  `[perf] api_call ${path} failed after ${(performance.now() - requestStart).toFixed(1)}ms`,
151
170
  );
152
- // IPC error - treat as network error
171
+ // IPC error - tauri's invoke() rejects with the raw String from a
172
+ // Result<T, String> command (not an Error instance), so this must
173
+ // check for a plain string too or the real message gets discarded.
153
174
  return {
154
175
  status: 0,
155
176
  body: JSON.stringify({
156
- error: err instanceof Error ? err.message : "IPC error",
177
+ error: ipcErrorMessage(err),
157
178
  }),
158
179
  };
159
180
  }
160
181
  }
161
182
 
162
183
  /**
163
- * upload - converts FormData to base64 JSON and calls dispatch
184
+ * upload - routes music/video to the chunked local-import path (see
185
+ * `uploadChunked`); everything else (images etc) still uses the legacy
186
+ * whole-file base64 path, which is fine at small file sizes.
164
187
  *
165
188
  * for tauri-local, we use wait_for_completion to block until the job
166
189
  * finishes, avoiding the need for polling from the client side.
@@ -168,16 +191,9 @@ export class CharnelLocalTransport implements Transport {
168
191
  async upload(
169
192
  path: string,
170
193
  formData: FormData,
171
- _onProgress?: (loaded: number, total: number) => void,
194
+ onProgress?: (loaded: number, total: number) => void,
195
+ metadata?: UploadMetadata,
172
196
  ): Promise<TransportResponse> {
173
- // no byte-level progress possible here - unlike CharnelTransport (P2P),
174
- // which already chunks the file for android/memory reasons and can
175
- // report progress per chunk, this local-dispatch path reads the whole
176
- // file into one base64 JSON body and sends it as a single IPC call.
177
- // chunking this too would need a new local (non-P2P) equivalent of the
178
- // `-by-blake3` upload route, since that route currently requires a
179
- // `node_id` and always pulls from a remote peer.
180
- // extract file and other fields from FormData
181
197
  const file = formData.get("file") as File | null;
182
198
  if (!file) {
183
199
  return {
@@ -190,6 +206,87 @@ export class CharnelLocalTransport implements Transport {
190
206
  };
191
207
  }
192
208
 
209
+ if (path === "/api/upload/music" || path === "/api/upload/video") {
210
+ return this.uploadChunked(path, file, metadata, onProgress);
211
+ }
212
+
213
+ return this.uploadLegacyBase64(path, file, metadata);
214
+ }
215
+
216
+ /**
217
+ * stream a music/video file to this local grimoire instance in bounded
218
+ * chunks via the local_import_* tauri commands (shared with
219
+ * CharnelTransport's P2P chunked import - see p2p_commands.rs), instead
220
+ * of buffering the whole file into one base64 JSON body. mirrors
221
+ * CharnelTransport.uploadMediaViaBytes, but finishes to a plain temp-file
222
+ * path (this device IS the destination, no remote peer to pull from)
223
+ * which is then handed to the existing `file_path`-based upload route.
224
+ *
225
+ * `onProgress`, if given, is called after each chunk with (bytes sent so
226
+ * far, file.size) - real, byte-level progress from the chunk loop itself.
227
+ */
228
+ private async uploadChunked(
229
+ path: string,
230
+ file: File,
231
+ metadata: UploadMetadata | undefined,
232
+ onProgress?: (loaded: number, total: number) => void,
233
+ ): Promise<TransportResponse> {
234
+ const inv = await ensureInvoke();
235
+
236
+ // ~4MB raw per chunk -> ~5.5MB base64 per IPC call, matching the P2P
237
+ // chunked import's chunk size.
238
+ const CHUNK_SIZE = 4 * 1024 * 1024;
239
+
240
+ const uploadId = (await inv("p2p_import_begin")) as string;
241
+ let filePath: string;
242
+ try {
243
+ for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
244
+ const slice = file.slice(offset, Math.min(offset + CHUNK_SIZE, file.size));
245
+ const chunkBytes = new Uint8Array(await slice.arrayBuffer());
246
+ await inv("p2p_import_chunk", { uploadId, data: bytesToBase64(chunkBytes) });
247
+ onProgress?.(Math.min(offset + chunkBytes.length, file.size), file.size);
248
+ }
249
+ filePath = (await inv("local_import_finish", { uploadId })) as string;
250
+ } catch (err) {
251
+ try {
252
+ await inv("p2p_import_abort", { uploadId });
253
+ } catch {
254
+ // ignore abort failures
255
+ }
256
+ throw err;
257
+ }
258
+
259
+ try {
260
+ const body: Record<string, unknown> = {
261
+ file_path: filePath,
262
+ filename: file.name,
263
+ wait_for_completion: true,
264
+ ...metadata,
265
+ };
266
+ return await this.request("POST", path, JSON.stringify(body));
267
+ } finally {
268
+ try {
269
+ await inv("local_import_cleanup", { filePath });
270
+ } catch {
271
+ // best-effort - a leaked temp file isn't worth failing the upload over
272
+ }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * @deprecated whole-file base64 upload - reads the entire file into
278
+ * memory and base64-encodes it in one shot, which OOM-crashed the
279
+ * android webview for large music/video files (see uploadChunked, which
280
+ * replaced this for those routes). only still used for small non-media
281
+ * uploads (e.g. images) where this is safe. do not extend this to any
282
+ * new large-file route - add it to the `uploadChunked` branch in
283
+ * `upload()` instead.
284
+ */
285
+ private async uploadLegacyBase64(
286
+ path: string,
287
+ file: File,
288
+ metadata?: UploadMetadata,
289
+ ): Promise<TransportResponse> {
193
290
  // read file as base64
194
291
  const arrayBuffer = await file.arrayBuffer();
195
292
  const base64 = btoa(
@@ -203,18 +300,9 @@ export class CharnelLocalTransport implements Transport {
203
300
  // tauri-local optimization: wait for job to complete instead of returning job_id
204
301
  // this eliminates the need for client-side polling
205
302
  wait_for_completion: true,
303
+ ...metadata,
206
304
  };
207
305
 
208
- // include associate_with if present
209
- const associationStr = formData.get("associate_with");
210
- if (associationStr && typeof associationStr === "string") {
211
- try {
212
- body.associate_with = JSON.parse(associationStr);
213
- } catch {
214
- // ignore parse errors
215
- }
216
- }
217
-
218
306
  // call through normal request path
219
307
  return this.request("POST", path, JSON.stringify(body));
220
308
  }
@@ -291,7 +379,7 @@ export class CharnelLocalTransport implements Transport {
291
379
  }
292
380
 
293
381
  // convert to asset URL and fetch via browser
294
- const assetUrl = convertFileSrc(pathInfo.path);
382
+ const assetUrl = mediaSrcFor(pathInfo.path);
295
383
  const fetchResponse = await fetch(assetUrl);
296
384
  const arrayBuffer = await fetchResponse.arrayBuffer();
297
385
 
@@ -339,13 +427,7 @@ export class CharnelLocalTransport implements Transport {
339
427
  /**
340
428
  * get blob URL — preference order:
341
429
  * 1. cached object URL (db-stored blobs)
342
- * 2. tauri asset:// (via `convertFileSrc`) on macos/windows
343
- * 3. linux fallback: fetch via asset:// and wrap in a blob: object URL
344
- * (webkitgtk can't stream asset:// into `<audio>`)
345
- *
346
- * the linux fallback buffers the ENTIRE file in memory, so it is never
347
- * used for video - a multi-GB local video would exhaust memory and hang
348
- * the app (`<video>` also seeks, which a one-shot blob can't stream).
430
+ * 2. tauri asset:// (via `convertFileSrc`), for any blob with a local path
349
431
  *
350
432
  * note: when the rodio audio backend is enabled (charnel + opt-in)
351
433
  * playback bypasses html `<audio>` entirely and reads files via
@@ -359,25 +441,15 @@ export class CharnelLocalTransport implements Transport {
359
441
  return cachedObjectUrl;
360
442
  }
361
443
 
362
- // on linux without rodio, we MUST go async to wrap in a blob:
363
- // url (asset:// can't stream into <audio> on webkitgtk) - except for
364
- // video, which streams from asset:// directly (see below).
365
- const cachedPath = this.blobPathCache.get(blobId);
366
- if (isLinuxWebKit && !isStreamableMime(cachedPath?.mime)) {
367
- console.debug(`[CharnelLocalTransport] blob ${blobId}: linux fallback (async)`);
368
- return this.getBlobUrlAsync(blobId);
369
- }
370
-
371
444
  // check path cache (filesystem blobs) — direct asset:// url
372
- const cached = cachedPath;
445
+ const cached = this.blobPathCache.get(blobId);
373
446
  if (cached && convertFileSrc) {
374
- const url = convertFileSrc(cached.path);
447
+ const url = mediaSrcFor(cached.path);
375
448
  console.debug(`[CharnelLocalTransport] blob ${blobId}: asset:// (cached) -> ${url}`);
376
449
  return url;
377
450
  }
378
451
 
379
452
  // need to fetch path (or data) first
380
- // console.debug(`[CharnelLocalTransport] blob ${blobId}: async path lookup`);
381
453
  return this.getBlobUrlAsync(blobId);
382
454
  }
383
455
 
@@ -400,22 +472,10 @@ export class CharnelLocalTransport implements Transport {
400
472
  throw new Error("convertFileSrc not available");
401
473
  }
402
474
 
403
- // on linux without media server: fall back to blob: workaround.
404
- // routes audio + image differently:
405
- // - audio: single-slot cache (revoke-on-replace) since audio
406
- // blobs are large and we only play one at a time
407
- // - other (images / waveforms / cover art): per-blob cache so
408
- // multiple `<img>` and css `background-image` urls coexist
409
- // across the playerbar, queue sidebar, etc.
410
- // video is excluded - buffering it would hang the app.
411
- if (isLinuxWebKit && !isStreamableMime(parsed.data.mime)) {
412
- return this.createBlobObjectUrl(blobId, parsed.data.path, parsed.data.mime);
413
- }
414
-
415
475
  console.debug(
416
476
  `[CharnelLocalTransport] blob ${blobId}: asset:// stream (mime=${parsed.data.mime ?? "?"})`,
417
477
  );
418
- return convertFileSrc(parsed.data.path);
478
+ return mediaSrcFor(parsed.data.path);
419
479
  }
420
480
  }
421
481
 
@@ -437,54 +497,6 @@ export class CharnelLocalTransport implements Transport {
437
497
  throw new Error(`failed to get blob path: ${response.body}`);
438
498
  }
439
499
 
440
- /**
441
- * create a blob: object URL by fetching via asset:// protocol.
442
- * used on linux where webkitgtk can't play asset:// in `<audio>`
443
- * elements (and historically also where the embedded http loopback
444
- * server stood in for the same workaround).
445
- *
446
- * mime-aware caching:
447
- * - `audio/*`: single-slot cache, revoke-on-replace. audio blobs
448
- * are large (often tens of MB) and only one ever plays at a
449
- * time, so leaking the rest is wasteful.
450
- * - everything else (images, waveforms, cover art): stored in
451
- * `blobObjectUrlCache` keyed by blob id so multiple `<img>` /
452
- * css `background-image` references coexist without one
453
- * revoking another.
454
- */
455
- private async createBlobObjectUrl(
456
- blobId: string,
457
- localPath: string,
458
- mime?: string,
459
- ): Promise<string> {
460
- if (!convertFileSrc) {
461
- throw new Error("convertFileSrc not available");
462
- }
463
-
464
- const isAudio = (mime ?? "").startsWith("audio/");
465
- const effectiveMime = mime ?? (isAudio ? "audio/mpeg" : "application/octet-stream");
466
-
467
- const assetUrl = convertFileSrc(localPath);
468
- const resp = await fetch(assetUrl);
469
- const arrayBuffer = await resp.arrayBuffer();
470
- const blob = new Blob([arrayBuffer], { type: effectiveMime });
471
- const objectUrl = URL.createObjectURL(blob);
472
-
473
- if (isAudio) {
474
- // revoke previous single-slot audio url (if any) so we don't
475
- // leak large buffers as the user moves between tracks.
476
- if (this.audioBlobUrl) {
477
- URL.revokeObjectURL(this.audioBlobUrl.url);
478
- }
479
- this.audioBlobUrl = { blobId, url: objectUrl };
480
- } else {
481
- // per-blob cache so e.g. the playerbar's waveform and the queue
482
- // sidebar's matching waveform share a single object url.
483
- this.blobObjectUrlCache.set(blobId, objectUrl);
484
- }
485
- return objectUrl;
486
- }
487
-
488
500
  // -----------------------------------------------------------------
489
501
  // job events (local ipc shortcut)
490
502
  //
@@ -3,11 +3,18 @@
3
3
  // uses Tauri IPC commands to make P2P requests via the server's
4
4
  // app iroh endpoint. no WASM needed.
5
5
 
6
- import type { BlobData, BlobFetchOptions, Transport, TransportResponse } from "./transport.js";
6
+ import type {
7
+ BlobData,
8
+ BlobFetchOptions,
9
+ Transport,
10
+ TransportResponse,
11
+ UploadMetadata,
12
+ } from "./transport.js";
7
13
  import type { BlobProgressCallback } from "./WasmTransport.js";
8
14
  import { isTauriRuntime } from "./tauriRuntime.js";
9
15
  import type { CloseReason, EventFilter, JobEvent, JobStateSnapshot } from "./codegen/schema.js";
10
16
  import { JobEventsStreamClosed } from "./CharnelLocalTransport.js";
17
+ import { bytesToBase64 } from "./base64.js";
11
18
 
12
19
  // tauri invoke function type
13
20
  type InvokeFn = (cmd: string, args?: unknown) => Promise<unknown>;
@@ -186,17 +193,6 @@ function base64ToBytes(base64: string): Uint8Array {
186
193
  return bytes;
187
194
  }
188
195
 
189
- /**
190
- * encode Uint8Array to base64 string
191
- */
192
- function bytesToBase64(bytes: Uint8Array): string {
193
- let binary = "";
194
- for (let i = 0; i < bytes.length; i++) {
195
- binary += String.fromCharCode(bytes[i]);
196
- }
197
- return btoa(binary);
198
- }
199
-
200
196
  /**
201
197
  * CharnelTransport - P2P transport using Tauri IPC commands
202
198
  * implements Transport interface for use with FreqholeClient
@@ -270,6 +266,7 @@ export class CharnelTransport implements Transport {
270
266
  path: string,
271
267
  formData: FormData,
272
268
  onProgress?: (loaded: number, total: number) => void,
269
+ metadata?: UploadMetadata,
273
270
  ): Promise<TransportResponse> {
274
271
  // extract file from form data
275
272
  const file = formData.get("file") as File | null;
@@ -296,11 +293,11 @@ export class CharnelTransport implements Transport {
296
293
  // the import is already chunked (see uploadMediaViaBytes), so real
297
294
  // per-chunk progress is reported here, same as HttpTransport's XHR path.
298
295
  if (path === "/api/upload/music" || path === "/api/upload/video") {
299
- return this.uploadMediaViaBytes(path, file, onProgress);
296
+ return this.uploadMediaViaBytes(path, file, onProgress, metadata);
300
297
  }
301
298
 
302
299
  // for non-media uploads (images etc), use base64 (small enough)
303
- return this.uploadViaBase64(path, file, formData);
300
+ return this.uploadViaBase64(path, file, metadata);
304
301
  }
305
302
 
306
303
  /**
@@ -356,7 +353,7 @@ export class CharnelTransport implements Transport {
356
353
  private async uploadViaBase64(
357
354
  path: string,
358
355
  file: File,
359
- formData: FormData,
356
+ metadata?: UploadMetadata,
360
357
  ): Promise<TransportResponse> {
361
358
  if (file.size > MAX_BASE64_UPLOAD_BYTES) {
362
359
  throw uploadTooLargeError(file, path);
@@ -368,18 +365,9 @@ export class CharnelTransport implements Transport {
368
365
  const body: Record<string, unknown> = {
369
366
  data: base64,
370
367
  filename: file.name,
368
+ ...metadata,
371
369
  };
372
370
 
373
- // include associate_with if present
374
- const associateWithStr = formData.get("associate_with") as string | null;
375
- if (associateWithStr) {
376
- try {
377
- body.associate_with = JSON.parse(associateWithStr);
378
- } catch {
379
- // ignore parse errors
380
- }
381
- }
382
-
383
371
  // send via api_request — routes through offal dispatch on the remote peer
384
372
  return this.request("POST", path, JSON.stringify(body));
385
373
  }
@@ -405,6 +393,7 @@ export class CharnelTransport implements Transport {
405
393
  path: string,
406
394
  file: File,
407
395
  onProgress?: (loaded: number, total: number) => void,
396
+ metadata?: UploadMetadata,
408
397
  ): Promise<TransportResponse> {
409
398
  const inv = await ensureInvoke();
410
399
 
@@ -439,7 +428,7 @@ export class CharnelTransport implements Transport {
439
428
  console.debug("[P2P] uploadMediaViaBytes: imported blob, blake3 =", blake3);
440
429
 
441
430
  // tell the remote peer to pull the blob from us
442
- const body = { blake3, filename: file.name };
431
+ const body = { blake3, filename: file.name, ...metadata };
443
432
  return tagStep("remote_trigger", () =>
444
433
  this.request("POST", `${path}-by-blake3`, JSON.stringify(body)),
445
434
  );
@@ -3,7 +3,13 @@
3
3
  // uses midden's MiddenNode to make API requests to peer nodes.
4
4
  // blobs are cached in Cache API for audio playback.
5
5
 
6
- import type { BlobData, BlobFetchOptions, Transport, TransportResponse } from "./transport.js";
6
+ import type {
7
+ BlobData,
8
+ BlobFetchOptions,
9
+ Transport,
10
+ TransportResponse,
11
+ UploadMetadata,
12
+ } from "./transport.js";
7
13
  import { snapshotJobEventsViaRequest } from "./transport.js";
8
14
  import type { CloseReason, EventFilter, JobEvent, JobStateSnapshot } from "./codegen/schema.js";
9
15
  import { JobEventsStreamClosed } from "./CharnelLocalTransport.js";
@@ -72,6 +78,16 @@ export interface MiddenNodeLike {
72
78
  // import bytes into local iroh-blobs store, returns blake3 hash (64 hex chars)
73
79
  // keeps a TempTag so GC won't collect it until release_blob is called
74
80
  import_blob?(data: Uint8Array): Promise<string>;
81
+ // chunked counterpart of import_blob - the wasm boundary never sees the
82
+ // whole payload at once (see lib/midden's ImportSession doc comment).
83
+ // push() is backpressured (resolves once the chunk is queued); finish()
84
+ // completes the import and returns the blake3 hash, pinned the same way
85
+ // import_blob's result is (until release_blob is called).
86
+ start_import?(): {
87
+ push(chunk: Uint8Array): Promise<void>;
88
+ finish(): Promise<string>;
89
+ abort(): void;
90
+ };
75
91
  // release a blob's TempTag, allowing GC
76
92
  release_blob?(blake3_hash: string): void;
77
93
  // start background accept loop for incoming iroh-blobs connections
@@ -84,6 +100,14 @@ export interface MiddenNodeLike {
84
100
  // download blob by ID with on-demand blake3 computation - optional
85
101
  // returns [Uint8Array, string] but typed as any[] for wasm-bindgen compatibility
86
102
  download_verified_by_id?(peer_addr: string, blob_id: string): Promise<any[]>;
103
+ // same as download_verified_by_id but reports incremental progress -
104
+ // on_progress receives a fraction in [0, 1]. returns [Uint8Array, string].
105
+ download_verified_by_id_progress?(
106
+ peer_addr: string,
107
+ blob_id: string,
108
+ total_size: number,
109
+ on_progress: (fraction: number) => void,
110
+ ): Promise<any[]>;
87
111
  // download blob and stream chunks via callback - preferred for large files
88
112
  // on_chunk receives (chunk: Uint8Array, offset: number)
89
113
  // on_progress receives (fraction: number) in [0, 1]
@@ -149,6 +173,14 @@ export type BlobProgressCallback = (received: number, total: number) => void;
149
173
  // unified cache for all remote blobs (HTTP + P2P) - default if no custom cache name provided
150
174
  const DEFAULT_CACHE_NAME = "freqhole-blobs-v1";
151
175
 
176
+ // retry ladder for a request that fails at the connection/stream level
177
+ // (e.g. "read error: connection lost") - mirrors playerPairingClient.ts's
178
+ // DIAL_RETRY_DELAYS_MS for the same underlying reason: a freshly-dialed
179
+ // p2p connection can still be settling and drop an early request even
180
+ // though the peer is genuinely reachable. short and small on purpose -
181
+ // this guards a single request, not a whole cold-dial handshake.
182
+ const REQUEST_RETRY_DELAYS_MS = [150, 400];
183
+
152
184
  /**
153
185
  * decode base64 string to Uint8Array
154
186
  * handles both standard and URL-safe base64 encoding
@@ -319,34 +351,49 @@ export class WasmTransport implements Transport {
319
351
  }
320
352
 
321
353
  async request(method: string, path: string, body?: string): Promise<TransportResponse> {
322
- try {
323
- // TEMP DEBUG - remove once the first-pair-attempt-fails bug is found
324
- console.log(`[debug/WasmTransport] request start ${method} ${path} -> ${this.peerAddr}`);
325
- const result = await this.node.api_request(this.peerAddr, method, path, body ?? null);
326
- // TEMP DEBUG - remove once the first-pair-attempt-fails bug is found
327
- console.log(`[debug/WasmTransport] request ok ${method} ${path} status=${result.status}`);
328
- if (result.status < 200 || result.status >= 300) {
329
- // TEMP DEBUG - remove once sync-to-local wiring bug is found
330
- console.log(`[debug/WasmTransport] ${method} ${path} non-2xx body:`, result.body);
354
+ let lastErr: unknown;
355
+ for (let attempt = 0; attempt <= REQUEST_RETRY_DELAYS_MS.length; attempt++) {
356
+ try {
357
+ const result = await this.node.api_request(this.peerAddr, method, path, body ?? null);
358
+ if (result.status < 200 || result.status >= 300) {
359
+ // TEMP DEBUG - remove once sync-to-local wiring bug is found
360
+ console.log(`[debug/WasmTransport] ${method} ${path} non-2xx body:`, result.body);
361
+ }
362
+ return {
363
+ status: result.status,
364
+ body: result.body,
365
+ };
366
+ } catch (e) {
367
+ lastErr = e;
368
+ // any exception here is a connection/stream-level failure (a real
369
+ // HTTP-ish response, even non-2xx, resolves normally above) - a
370
+ // freshly-dialed p2p peer's connection can still be settling (NAT
371
+ // traversal/relay handoff) and drop an early request with "read
372
+ // error: connection lost" even though the peer itself is fine, as
373
+ // seen live pushing a queued song to a just-paired player. retrying
374
+ // a few times with a short delay (same ladder shape as
375
+ // playerPairingClient.ts's cold-dial retry) instead of failing
376
+ // the whole resolve immediately.
377
+ if (attempt < REQUEST_RETRY_DELAYS_MS.length) {
378
+ console.warn(
379
+ `[WasmTransport] ${method} ${path} attempt ${attempt + 1} failed, retrying:`,
380
+ e,
381
+ );
382
+ await new Promise((resolve) => setTimeout(resolve, REQUEST_RETRY_DELAYS_MS[attempt]));
383
+ continue;
384
+ }
331
385
  }
332
- return {
333
- status: result.status,
334
- body: result.body,
335
- };
336
- } catch (e) {
337
- // P2P connection errors - rethrow with message that isNetworkError will catch
338
- const { message, errorType } = extractErrorType(e);
339
- // TEMP DEBUG - remove once sync-to-local wiring bug is found
340
- console.log(`[debug/WasmTransport] ${method} ${path} threw:`, e);
341
- console.warn(`[WasmTransport] P2P request failed: ${message}`);
342
- throw new TransportError(`connection failed: ${message}`, { errorType });
343
386
  }
387
+ const { message, errorType } = extractErrorType(lastErr);
388
+ console.warn(`[WasmTransport] P2P request failed: ${message}`);
389
+ throw new TransportError(`connection failed: ${message}`, { errorType });
344
390
  }
345
391
 
346
392
  async upload(
347
393
  path: string,
348
394
  formData: FormData,
349
395
  _onProgress?: (loaded: number, total: number) => void,
396
+ metadata?: UploadMetadata,
350
397
  ): Promise<TransportResponse> {
351
398
  // no byte-level progress possible here - unlike CharnelTransport's tauri
352
399
  // IPC path (which self-chunks and can report per-chunk progress), the
@@ -375,11 +422,11 @@ export class WasmTransport implements Transport {
375
422
  // available - chunked/verified streaming, no base64/raw-bytes framing.
376
423
  const blobPullPaths = ["/api/upload/music", "/api/upload/video"];
377
424
  if (blobPullPaths.includes(path) && this.node.import_blob) {
378
- return this.uploadViaIrohBlobs(path, file, formData);
425
+ return this.uploadViaIrohBlobs(path, file, metadata);
379
426
  }
380
427
 
381
428
  // fallback: base64 encode and send via api_request (works for image uploads)
382
- return this.uploadViaBase64(path, file, formData);
429
+ return this.uploadViaBase64(path, file, metadata);
383
430
  }
384
431
 
385
432
  /**
@@ -392,7 +439,7 @@ export class WasmTransport implements Transport {
392
439
  private async uploadViaIrohBlobs(
393
440
  path: string,
394
441
  file: File,
395
- formData: FormData,
442
+ metadata?: UploadMetadata,
396
443
  ): Promise<TransportResponse> {
397
444
  try {
398
445
  const fileBytes = new Uint8Array(await file.arrayBuffer());
@@ -403,28 +450,9 @@ export class WasmTransport implements Transport {
403
450
  blake3: hash,
404
451
  filename: file.name,
405
452
  size: fileBytes.length,
453
+ ...metadata,
406
454
  };
407
455
 
408
- // include metadata if present (parsed as JSON)
409
- const metadataStr = formData.get("metadata") as string | null;
410
- if (metadataStr) {
411
- try {
412
- body.metadata = JSON.parse(metadataStr);
413
- } catch {
414
- // ignore parse errors
415
- }
416
- }
417
-
418
- // include associate_with if present
419
- const associateWithStr = formData.get("associate_with") as string | null;
420
- if (associateWithStr) {
421
- try {
422
- body.associate_with = JSON.parse(associateWithStr);
423
- } catch {
424
- // ignore parse errors
425
- }
426
- }
427
-
428
456
  const response = await this.request("POST", `${path}-by-blake3`, JSON.stringify(body));
429
457
  return response;
430
458
  } finally {
@@ -449,7 +477,7 @@ export class WasmTransport implements Transport {
449
477
  private async uploadViaBase64(
450
478
  path: string,
451
479
  file: File,
452
- formData: FormData,
480
+ metadata?: UploadMetadata,
453
481
  ): Promise<TransportResponse> {
454
482
  if (path === "/api/upload/music" || path === "/api/upload/video") {
455
483
  console.warn(
@@ -469,18 +497,9 @@ export class WasmTransport implements Transport {
469
497
  const body: Record<string, unknown> = {
470
498
  data: base64,
471
499
  filename: file.name,
500
+ ...metadata,
472
501
  };
473
502
 
474
- // include associate_with if present
475
- const associateWithStr = formData.get("associate_with") as string | null;
476
- if (associateWithStr) {
477
- try {
478
- body.associate_with = JSON.parse(associateWithStr);
479
- } catch {
480
- // ignore parse errors
481
- }
482
- }
483
-
484
503
  // send via api_request — routes through offal dispatch on the remote peer
485
504
  return this.request("POST", path, JSON.stringify(body));
486
505
  }
@@ -755,14 +774,29 @@ export class WasmTransport implements Transport {
755
774
  // fallback because `/api/blobs/{id}/data` only works for DB-backed blobs
756
775
  // and file-backed media blobs would otherwise fail first, then fall back
757
776
  // anyway.
758
- if (!blake3 && this.node.download_verified_by_id && mimeType?.startsWith("audio/")) {
777
+ // download_verified_by_id_progress always ships alongside plain
778
+ // download_verified_by_id in the same midden build (both are generated
779
+ // from the same wasm-bindgen pass) - there's no real scenario where
780
+ // only the non-progress variant is available, so this only checks for
781
+ // the progress-capable one. the plain variant has no progress hook at
782
+ // all - calling it here would only ever report one 0->100% jump at
783
+ // completion instead of the incremental updates a multi-second
784
+ // download needs for a usable progress bar.
785
+ if (!blake3 && this.node.download_verified_by_id_progress && mimeType?.startsWith("audio/")) {
759
786
  try {
760
- const result = await this.node.download_verified_by_id(this.peerAddr, blobId);
787
+ const result = await this.node.download_verified_by_id_progress(
788
+ this.peerAddr,
789
+ blobId,
790
+ totalBytes ?? 0,
791
+ (fraction: number) => {
792
+ if (totalBytes && totalBytes > 0) {
793
+ onProgress(Math.min(totalBytes, Math.floor(fraction * totalBytes)), totalBytes);
794
+ }
795
+ },
796
+ );
761
797
  const data = result[0] as Uint8Array;
762
798
  const contentType = mimeType || "application/octet-stream";
763
799
 
764
- onProgress(data.length, data.length);
765
-
766
800
  await this.storeInCache(cache, blobId, data, contentType, opts);
767
801
 
768
802
  return { data, contentType };
@@ -816,14 +850,23 @@ export class WasmTransport implements Transport {
816
850
  // this matches fetchBlob() so callers using the progress path (radio
817
851
  // timeline playback) do not fail just because the API response omitted
818
852
  // blake3 for a song.
819
- if (!blake3 && this.node.download_verified_by_id) {
853
+ if (!blake3 && this.node.download_verified_by_id_progress) {
820
854
  try {
821
- const result = await this.node.download_verified_by_id(this.peerAddr, blobId);
855
+ // see the audio-specific branch above for why only the progress
856
+ // variant is checked for here.
857
+ const result = await this.node.download_verified_by_id_progress(
858
+ this.peerAddr,
859
+ blobId,
860
+ totalBytes ?? 0,
861
+ (fraction: number) => {
862
+ if (totalBytes && totalBytes > 0) {
863
+ onProgress(Math.min(totalBytes, Math.floor(fraction * totalBytes)), totalBytes);
864
+ }
865
+ },
866
+ );
822
867
  const data = result[0] as Uint8Array;
823
868
  const contentType = mimeType || "application/octet-stream";
824
869
 
825
- onProgress(data.length, data.length);
826
-
827
870
  await this.storeInCache(cache, blobId, data, contentType, opts);
828
871
 
829
872
  return { data, contentType };
package/src/base64.ts ADDED
@@ -0,0 +1,16 @@
1
+ // shared byte <-> base64 helpers for chunked tauri IPC uploads.
2
+ // extracted from CharnelTransport.ts so CharnelLocalTransport.ts's chunked
3
+ // upload path can reuse the exact same, already-proven-safe encoding.
4
+
5
+ /**
6
+ * encode a Uint8Array to a base64 string.
7
+ * only ever called with bounded chunk sizes (a few MB at most) by callers
8
+ * in this package - never the whole file at once.
9
+ */
10
+ export function bytesToBase64(bytes: Uint8Array): string {
11
+ let binary = "";
12
+ for (let i = 0; i < bytes.length; i++) {
13
+ binary += String.fromCharCode(bytes[i]);
14
+ }
15
+ return btoa(binary);
16
+ }
@@ -142,6 +142,7 @@ export const routes = {
142
142
  get_blob_thumbnail: { method: 'GET', path: '/api/blobs/{id}/thumb/{size}', req: null, resp: null, auth: { type: 'authenticated' } as const },
143
143
  get_enrichment_progress: { method: 'POST', path: '/api/music/albums/enrichment/progress', req: s.GetEnrichmentProgressRequestSchema, resp: s.GetEnrichmentProgressResponseSchema, auth: { type: 'role', role: 'admin' } as const },
144
144
  get_fetch_job: { method: 'POST', path: '/api/music/fetch/status', req: s.GetJobRequestSchema, resp: s.JobResponseSchema, auth: { type: 'authenticated' } as const },
145
+ get_import_session_target: { method: 'POST', path: '/api/music/import/session-target', req: s.GetImportSessionTargetRequestSchema, resp: s.ImportSessionSendTargetSchema, auth: { type: 'authenticated' } as const },
145
146
  get_job_status: { method: 'POST', path: '/api/jobs/status', req: s.GetJobsStatusRequestSchema, resp: s.GetJobsStatusResponseSchema, auth: { type: 'authenticated' } as const },
146
147
  get_musicbrainz_release: { method: 'POST', path: '/api/musicbrainz/release', req: s.GetReleaseRequestSchema, resp: s.MbReleaseDetailSchema, auth: { type: 'role', role: 'admin' } as const },
147
148
  get_playback_session: { method: 'POST', path: '/api/analytics/sessions/get', req: s.GetPlaybackSessionRequestSchema, resp: s.PlaybackSessionSchema, auth: { type: 'authenticated' } as const },
@@ -248,6 +249,7 @@ export const routes = {
248
249
  get_video_series: { method: 'POST', path: '/api/video/series/get', req: s.GetVideoSeriesRequestSchema, resp: s.VideoSeriesSchema, auth: { type: 'authenticated' } as const },
249
250
  get_video_series_detail: { method: 'POST', path: '/api/video/series/detail', req: s.GetVideoSeriesRequestSchema, resp: s.SeriesDetailSchema, auth: { type: 'authenticated' } as const },
250
251
  get_video_with_metadata: { method: 'POST', path: '/api/video/videos/get-with-metadata', req: s.GetVideoRequestSchema, resp: s.VideoWithMetadataSchema, auth: { type: 'authenticated' } as const },
252
+ import_video_paths: { method: 'POST', path: '/api/upload/video-paths', req: null, resp: s.VideoImportResponseSchema, auth: { type: 'role', role: 'member' } as const },
251
253
  list_pending_video_import_review: { method: 'POST', path: '/api/video/import/pending', req: s.ListPendingVideoReviewRequestSchema, resp: s.PendingVideoReviewSessionSchema.array(), auth: { type: 'authenticated' } as const },
252
254
  list_playback_progress: { method: 'POST', path: '/api/video/progress/list', req: s.ListPlaybackProgressRequestSchema, resp: s.PlaybackProgressSchema.array(), auth: { type: 'authenticated' } as const },
253
255
  list_video_seasons: { method: 'POST', path: '/api/video/seasons/list', req: s.ListVideoSeasonsRequestSchema, resp: s.VideoSeasonSchema.array(), auth: { type: 'authenticated' } as const },
@@ -1285,6 +1285,12 @@ export const AudioDbAlbumDetailResultSchema = z.object({
1285
1285
  });
1286
1286
  export type AudioDbAlbumDetailResult = z.infer<typeof AudioDbAlbumDetailResultSchema>;
1287
1287
 
1288
+ export const AudioDeviceInfoSchema = z.object({
1289
+ name: z.string(),
1290
+ description: z.string()
1291
+ });
1292
+ export type AudioDeviceInfo = z.infer<typeof AudioDeviceInfoSchema>;
1293
+
1288
1294
  export const AutoConfirmMbMatchesRequestSchema = z.object({
1289
1295
  album_ids: z.array(z.string()),
1290
1296
  min_confidence: z.number(),
@@ -1931,6 +1937,14 @@ export const EventFilterSchema = z.object({
1931
1937
  });
1932
1938
  export type EventFilter = z.infer<typeof EventFilterSchema>;
1933
1939
 
1940
+ export const ExistingImportedFileSchema = z.object({
1941
+ file_path: z.string(),
1942
+ song_id: z.string().nullish(),
1943
+ album_id: z.string().nullish(),
1944
+ video_id: z.string().nullish()
1945
+ });
1946
+ export type ExistingImportedFile = z.infer<typeof ExistingImportedFileSchema>;
1947
+
1934
1948
  export const ExternalUrlSchema = z.object({
1935
1949
  name: z.string(),
1936
1950
  url: z.string()
@@ -2953,6 +2967,11 @@ export const GetFavoriteStatusBulkRequestSchema = z.object({
2953
2967
  });
2954
2968
  export type GetFavoriteStatusBulkRequest = z.infer<typeof GetFavoriteStatusBulkRequestSchema>;
2955
2969
 
2970
+ export const GetImportSessionTargetRequestSchema = z.object({
2971
+ session_id: z.string()
2972
+ });
2973
+ export type GetImportSessionTargetRequest = z.infer<typeof GetImportSessionTargetRequestSchema>;
2974
+
2956
2975
  export const GetJobRequestSchema = z.object({
2957
2976
  job_id: z.string()
2958
2977
  });
@@ -3116,6 +3135,12 @@ export const ImportReviewOkSchema = z.object({
3116
3135
  });
3117
3136
  export type ImportReviewOk = z.infer<typeof ImportReviewOkSchema>;
3118
3137
 
3138
+ export const ImportSessionSendTargetSchema = z.object({
3139
+ target_remote_id: z.string().nullish(),
3140
+ target_remote_name: z.string().nullish()
3141
+ });
3142
+ export type ImportSessionSendTarget = z.infer<typeof ImportSessionSendTargetSchema>;
3143
+
3119
3144
  export const IngestRemoteImageRequestSchema = z.object({
3120
3145
  remote_url: z.string(),
3121
3146
  target: z.union([z.object({
@@ -4373,7 +4398,13 @@ export const MusicImportResponseSchema = z.object({
4373
4398
  jobs_created: z.number(),
4374
4399
  directories_scanned: z.number(),
4375
4400
  files_skipped: z.number(),
4376
- message: z.string()
4401
+ message: z.string(),
4402
+ existing_files: z.array(z.object({
4403
+ file_path: z.string(),
4404
+ song_id: z.string().nullish(),
4405
+ album_id: z.string().nullish(),
4406
+ video_id: z.string().nullish()
4407
+ }))
4377
4408
  });
4378
4409
  export type MusicImportResponse = z.infer<typeof MusicImportResponseSchema>;
4379
4410
 
@@ -4480,7 +4511,9 @@ export const PendingReviewSessionSchema = z.object({
4480
4511
  artwork_blob_id: z.string().nullish(),
4481
4512
  song_count: z.number(),
4482
4513
  pending_blob_count: z.number()
4483
- }))
4514
+ })),
4515
+ target_remote_id: z.string().nullish(),
4516
+ target_remote_name: z.string().nullish()
4484
4517
  });
4485
4518
  export type PendingReviewSession = z.infer<typeof PendingReviewSessionSchema>;
4486
4519
 
@@ -4532,7 +4565,9 @@ export const PendingVideoReviewSessionSchema = z.object({
4532
4565
  episode_number: z.number().nullish()
4533
4566
  })),
4534
4567
  pending_blob_count: z.number()
4535
- }))
4568
+ })),
4569
+ target_remote_id: z.string().nullish(),
4570
+ target_remote_name: z.string().nullish()
4536
4571
  });
4537
4572
  export type PendingVideoReviewSession = z.infer<typeof PendingVideoReviewSessionSchema>;
4538
4573
 
@@ -4604,7 +4639,9 @@ z.object({ kind: z.literal("next") }),
4604
4639
  z.object({ kind: z.literal("previous") }),
4605
4640
  z.object({ kind: z.literal("seek"), ms: z.number() }),
4606
4641
  z.object({ kind: z.literal("set_volume"), v: z.number() }),
4607
- z.object({ kind: z.literal("status") })
4642
+ z.object({ kind: z.literal("status") }),
4643
+ z.object({ kind: z.literal("list_output_devices") }),
4644
+ z.object({ kind: z.literal("set_output_device"), name: z.string() })
4608
4645
  ]);
4609
4646
  export type PlayerCommand = z.infer<typeof PlayerCommandSchema>;
4610
4647
 
@@ -4615,7 +4652,8 @@ z.object({ kind: z.literal("track_changed"), index: z.number(), path: z.string()
4615
4652
  z.object({ kind: z.literal("ended") }),
4616
4653
  z.object({ kind: z.literal("error"), detail: ErrorDetailSchema }),
4617
4654
  z.object({ kind: z.literal("backend_down"), restart_count: z.number() }),
4618
- z.object({ kind: z.literal("backend_up") })
4655
+ z.object({ kind: z.literal("backend_up") }),
4656
+ z.object({ kind: z.literal("output_devices"), devices: z.array(AudioDeviceInfoSchema) })
4619
4657
  ]);
4620
4658
  export type PlayerEvent = z.infer<typeof PlayerEventSchema>;
4621
4659
 
@@ -7744,6 +7782,21 @@ export const VideoSchema = z.object({
7744
7782
  });
7745
7783
  export type Video = z.infer<typeof VideoSchema>;
7746
7784
 
7785
+ export const VideoImportResponseSchema = z.object({
7786
+ session_id: z.string(),
7787
+ jobs_created: z.number(),
7788
+ directories_scanned: z.number(),
7789
+ files_skipped: z.number(),
7790
+ message: z.string(),
7791
+ existing_files: z.array(z.object({
7792
+ file_path: z.string(),
7793
+ song_id: z.string().nullish(),
7794
+ album_id: z.string().nullish(),
7795
+ video_id: z.string().nullish()
7796
+ }))
7797
+ });
7798
+ export type VideoImportResponse = z.infer<typeof VideoImportResponseSchema>;
7799
+
7747
7800
  export const VideoImportReviewOkSchema = z.object({
7748
7801
  ok: z.boolean()
7749
7802
  });
@@ -7774,7 +7827,10 @@ export const VideoRenditionSchema = z.object({
7774
7827
  label: z.string(),
7775
7828
  extension: z.string(),
7776
7829
  mime: z.string().nullish(),
7777
- skipped: z.boolean()
7830
+ skipped: z.boolean(),
7831
+ blake3: z.string().nullish(),
7832
+ width: z.number().nullish(),
7833
+ height: z.number().nullish()
7778
7834
  });
7779
7835
  export type VideoRendition = z.infer<typeof VideoRenditionSchema>;
7780
7836
 
@@ -1818,6 +1818,21 @@ export function createMusicMethods(call: CallFn) {
1818
1818
  );
1819
1819
  },
1820
1820
 
1821
+ // look up a session's send target directly - unlike
1822
+ // listPendingImportReview, still resolves after the session's last
1823
+ // album has been marked reviewed (see GetImportSessionTargetRequest).
1824
+ getImportSessionTarget: (params: s.GetImportSessionTargetRequest) => {
1825
+ return call(
1826
+ "music",
1827
+ "get_import_session_target",
1828
+ routes.music.get_import_session_target.resp,
1829
+ routes.music.get_import_session_target.req,
1830
+ routes.music.get_import_session_target.method,
1831
+ routes.music.get_import_session_target.path,
1832
+ params,
1833
+ );
1834
+ },
1835
+
1821
1836
  markAlbumReviewed: (params: s.MarkAlbumReviewedRequest) => {
1822
1837
  return call(
1823
1838
  "music",
@@ -7,9 +7,10 @@ import {
7
7
  ImageUploadResponseSchema,
8
8
  MusicImportResponseSchema,
9
9
  MusicUploadResponseSchema,
10
+ VideoImportResponseSchema,
10
11
  VideoUploadResponseSchema,
11
12
  } from "../codegen/schema.js";
12
- import type { Transport } from "../transport.js";
13
+ import type { Transport, UploadMetadata } from "../transport.js";
13
14
  import type { SafeParseResult } from "./types.js";
14
15
  import { toZodError } from "../errors.js";
15
16
 
@@ -51,6 +52,25 @@ export type UploadImageOptions = {
51
52
  associate?: s.AssociationHint;
52
53
  };
53
54
 
55
+ /** "review before send" annotation - see grimoire's
56
+ * import_session_send_targetz. when set, the session this upload lands
57
+ * in gets tagged so any client (any device, any restart) can see it
58
+ * should ultimately be sent to this remote once reviewed. ergonomic
59
+ * camelCase call-site shape - converted to `UploadMetadata`'s snake_case
60
+ * wire fields at the `transport.upload()` boundary below. */
61
+ export interface ImportSendTargetOptions {
62
+ targetRemoteId?: string;
63
+ targetRemoteName?: string;
64
+ }
65
+
66
+ function toUploadMetadata(options?: ImportSendTargetOptions): UploadMetadata | undefined {
67
+ if (!options?.targetRemoteId && !options?.targetRemoteName) return undefined;
68
+ return {
69
+ target_remote_id: options.targetRemoteId,
70
+ target_remote_name: options.targetRemoteName,
71
+ };
72
+ }
73
+
54
74
  export function createUploadMethods(transport: Transport) {
55
75
  return {
56
76
  /**
@@ -61,11 +81,17 @@ export function createUploadMethods(transport: Transport) {
61
81
  music: async (
62
82
  file: File | Blob,
63
83
  onProgress?: (loaded: number, total: number) => void,
84
+ options?: ImportSendTargetOptions,
64
85
  ): Promise<SafeParseResult<s.MusicUploadResponse>> => {
65
86
  const formData = new FormData();
66
87
  formData.append("file", file);
67
88
 
68
- const response = await transport.upload("/api/upload/music", formData, onProgress);
89
+ const response = await transport.upload(
90
+ "/api/upload/music",
91
+ formData,
92
+ onProgress,
93
+ toUploadMetadata(options),
94
+ );
69
95
  return parseResponse(response.body, response.status, MusicUploadResponseSchema);
70
96
  },
71
97
 
@@ -144,11 +170,11 @@ export function createUploadMethods(transport: Transport) {
144
170
  const formData = new FormData();
145
171
  formData.append("file", file);
146
172
 
147
- if (options?.associate) {
148
- formData.append("associate_with", JSON.stringify(options.associate));
149
- }
173
+ const metadata: UploadMetadata | undefined = options?.associate
174
+ ? { associate_with: options.associate }
175
+ : undefined;
150
176
 
151
- const response = await transport.upload("/api/upload/image", formData);
177
+ const response = await transport.upload("/api/upload/image", formData, undefined, metadata);
152
178
  return parseResponse(response.body, response.status, ImageUploadResponseSchema);
153
179
  },
154
180
 
@@ -195,11 +221,13 @@ export function createUploadMethods(transport: Transport) {
195
221
  */
196
222
  musicByPaths: async (
197
223
  paths: string[],
198
- options?: { waitForCompletion?: boolean },
224
+ options?: { waitForCompletion?: boolean } & ImportSendTargetOptions,
199
225
  ): Promise<SafeParseResult<s.MusicImportResponse>> => {
200
226
  const body = {
201
227
  paths,
202
228
  wait_for_completion: options?.waitForCompletion ?? false,
229
+ target_remote_id: options?.targetRemoteId,
230
+ target_remote_name: options?.targetRemoteName,
203
231
  };
204
232
 
205
233
  const response = await transport.request(
@@ -209,6 +237,34 @@ export function createUploadMethods(transport: Transport) {
209
237
  );
210
238
  return parseResponse(response.body, response.status, MusicImportResponseSchema);
211
239
  },
240
+
241
+ /**
242
+ * import video files by filesystem paths (tauri-local only)
243
+ * accepts file paths or directory paths (directories are scanned recursively)
244
+ * bypasses file transfer since files are already local - mirrors musicByPaths
245
+ *
246
+ * @param paths - array of file or directory paths to import
247
+ * @param options - optional settings
248
+ * @param options.waitForCompletion - if true, wait for all jobs to complete (up to 5 min)
249
+ */
250
+ videoByPaths: async (
251
+ paths: string[],
252
+ options?: { waitForCompletion?: boolean } & ImportSendTargetOptions,
253
+ ): Promise<SafeParseResult<s.VideoImportResponse>> => {
254
+ const body = {
255
+ paths,
256
+ wait_for_completion: options?.waitForCompletion ?? false,
257
+ target_remote_id: options?.targetRemoteId,
258
+ target_remote_name: options?.targetRemoteName,
259
+ };
260
+
261
+ const response = await transport.request(
262
+ "POST",
263
+ "/api/upload/video-paths",
264
+ JSON.stringify(body),
265
+ );
266
+ return parseResponse(response.body, response.status, VideoImportResponseSchema);
267
+ },
212
268
  };
213
269
  }
214
270
 
@@ -469,6 +469,22 @@ export function createVideoMethods(call: CallFn) {
469
469
  params,
470
470
  );
471
471
  },
472
+
473
+ // sync a video by blake3 hash from a remote peer - reused for both
474
+ // pull-into-local (syncVideoViaLocalGrimoire.ts) and push-to-remote
475
+ // (sendVideoToRemote.ts): the caller decides direction purely by which
476
+ // instance it POSTs to and what it puts in `source_node_id`.
477
+ syncVideoByBlake3: (params: s.SyncVideoByBlake3Request) => {
478
+ return call(
479
+ "video",
480
+ "sync_video_by_blake3",
481
+ routes.video.sync_video_by_blake3.resp,
482
+ routes.video.sync_video_by_blake3.req,
483
+ routes.video.sync_video_by_blake3.method,
484
+ routes.video.sync_video_by_blake3.path,
485
+ params,
486
+ );
487
+ },
472
488
  };
473
489
  }
474
490
 
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ export {
31
31
  CharnelLocalTransport,
32
32
  createCharnelLocalTransport,
33
33
  JobEventsStreamClosed,
34
+ resolveCharnelMediaSrc,
34
35
  } from "./CharnelLocalTransport.js";
35
36
  export type {
36
37
  MiddenNodeLike,
package/src/transport.ts CHANGED
@@ -4,7 +4,32 @@
4
4
  // FreqholeClient uses a transport to make requests, then handles
5
5
  // Zod validation on top.
6
6
 
7
- import type { CloseReason, EventFilter, JobEvent, JobStateSnapshot } from "./codegen/schema.js";
7
+ import type {
8
+ AssociationHint,
9
+ CloseReason,
10
+ EventFilter,
11
+ JobEvent,
12
+ JobStateSnapshot,
13
+ } from "./codegen/schema.js";
14
+
15
+ /**
16
+ * typed metadata for an upload, passed as `Transport.upload()`'s 4th
17
+ * argument. P2P/IPC transports (CharnelLocalTransport, CharnelTransport,
18
+ * WasmTransport) consume this object directly - no FormData string
19
+ * parsing involved. `HttpTransport` is the only implementation that has
20
+ * to fold it back into real multipart form fields, since that's what its
21
+ * wire format actually is. see docs/add-media-review-refactor-plan.md §6
22
+ * for why this replaced the previous FormData-only design.
23
+ */
24
+ export interface UploadMetadata {
25
+ /** associate the uploaded blob with an existing entity (album/song/etc). */
26
+ associate_with?: AssociationHint;
27
+ /** "review before send" annotation - see grimoire's
28
+ * import_session_send_targetz - tags the resulting session as destined
29
+ * for this remote once reviewed. */
30
+ target_remote_id?: string;
31
+ target_remote_name?: string;
32
+ }
8
33
 
9
34
  /**
10
35
  * response from a transport request
@@ -59,12 +84,16 @@ export interface Transport {
59
84
  * progress event - plain fetch has no cross-browser upload progress
60
85
  * API); P2P/tauri transports accept but ignore it since their upload
61
86
  * path doesn't stream raw bytes over a trackable request body.
87
+ * @param metadata - typed upload metadata (association hint, review-before-
88
+ * send target). non-HTTP transports use this directly; HttpTransport
89
+ * folds it into real form fields internally.
62
90
  * @returns response with status code and body string
63
91
  */
64
92
  upload(
65
93
  path: string,
66
94
  formData: FormData,
67
95
  onProgress?: (loaded: number, total: number) => void,
96
+ metadata?: UploadMetadata,
68
97
  ): Promise<TransportResponse>;
69
98
 
70
99
  /**
@@ -196,7 +225,20 @@ export class HttpTransport implements Transport {
196
225
  path: string,
197
226
  formData: FormData,
198
227
  onProgress?: (loaded: number, total: number) => void,
228
+ metadata?: UploadMetadata,
199
229
  ): Promise<TransportResponse> {
230
+ // this is the one transport whose wire format actually is multipart
231
+ // form fields - fold the typed metadata back into FormData here so
232
+ // every other transport (and every caller) can deal with a plain
233
+ // object instead.
234
+ if (metadata?.associate_with) {
235
+ formData.append("associate_with", JSON.stringify(metadata.associate_with));
236
+ }
237
+ if (metadata?.target_remote_id) formData.append("target_remote_id", metadata.target_remote_id);
238
+ if (metadata?.target_remote_name) {
239
+ formData.append("target_remote_name", metadata.target_remote_name);
240
+ }
241
+
200
242
  const url = this.baseUrl + path;
201
243
 
202
244
  // plain fetch has no cross-browser upload-progress event, so only
@@ -0,0 +1,142 @@
1
+ // tests for the typed `UploadMetadata` param on `Transport.upload()` (see
2
+ // docs/add-media-review-refactor-plan.md §6 in the tomb repo).
3
+ //
4
+ // previously, "review before send" target info and the image
5
+ // `associate_with` hint were smuggled through FormData string fields that
6
+ // every non-HTTP transport had to parse back out. now they're a plain typed
7
+ // object passed as `upload()`'s 4th argument - only `HttpTransport` (whose
8
+ // wire format actually is multipart form fields) converts it back to
9
+ // FormData internally.
10
+
11
+ import { describe, expect, it, vi } from "vitest";
12
+ import { HttpTransport } from "./transport.js";
13
+ import { createUploadMethods } from "./domains/upload.js";
14
+ import type { Transport, TransportResponse, UploadMetadata } from "./transport.js";
15
+
16
+ function okResponse(data: unknown): TransportResponse {
17
+ return { status: 200, body: JSON.stringify({ success: true, data }) };
18
+ }
19
+
20
+ describe("HttpTransport.upload metadata -> FormData", () => {
21
+ it("folds associate_with and target_remote_id/name into real form fields", async () => {
22
+ let capturedFormData: FormData | undefined;
23
+ const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
24
+ capturedFormData = init.body as FormData;
25
+ return new Response(JSON.stringify({ success: true, data: {} }), { status: 200 });
26
+ });
27
+ vi.stubGlobal("fetch", fetchMock);
28
+
29
+ const transport = new HttpTransport("https://example.test");
30
+ const formData = new FormData();
31
+ formData.append("file", new Blob(["x"]), "song.mp3");
32
+ const metadata: UploadMetadata = {
33
+ associate_with: {
34
+ entity_type: "album",
35
+ entity_id: "abc",
36
+ } as UploadMetadata["associate_with"],
37
+ target_remote_id: "remote-1",
38
+ target_remote_name: "my server",
39
+ };
40
+
41
+ await transport.upload("/api/upload/music", formData, undefined, metadata);
42
+
43
+ expect(capturedFormData).toBeDefined();
44
+ expect(capturedFormData!.get("target_remote_id")).toBe("remote-1");
45
+ expect(capturedFormData!.get("target_remote_name")).toBe("my server");
46
+ expect(JSON.parse(capturedFormData!.get("associate_with") as string)).toEqual({
47
+ entity_type: "album",
48
+ entity_id: "abc",
49
+ });
50
+
51
+ vi.unstubAllGlobals();
52
+ });
53
+
54
+ it("omits fields entirely when no metadata is passed", async () => {
55
+ let capturedFormData: FormData | undefined;
56
+ vi.stubGlobal(
57
+ "fetch",
58
+ vi.fn(async (_url: string, init: RequestInit) => {
59
+ capturedFormData = init.body as FormData;
60
+ return new Response(JSON.stringify({ success: true, data: {} }), { status: 200 });
61
+ }),
62
+ );
63
+
64
+ const transport = new HttpTransport("https://example.test");
65
+ const formData = new FormData();
66
+ formData.append("file", new Blob(["x"]), "song.mp3");
67
+
68
+ await transport.upload("/api/upload/music", formData);
69
+
70
+ expect(capturedFormData!.get("target_remote_id")).toBeNull();
71
+ expect(capturedFormData!.get("target_remote_name")).toBeNull();
72
+ expect(capturedFormData!.get("associate_with")).toBeNull();
73
+
74
+ vi.unstubAllGlobals();
75
+ });
76
+ });
77
+
78
+ describe("domains/upload.ts music()/image() metadata passthrough", () => {
79
+ it("music() passes targetRemoteId/targetRemoteName as typed metadata, not FormData fields", async () => {
80
+ let receivedFormData: FormData | undefined;
81
+ let receivedMetadata: UploadMetadata | undefined;
82
+ const fakeTransport: Pick<Transport, "upload"> = {
83
+ upload: async (_path, formData, _onProgress, metadata) => {
84
+ receivedFormData = formData;
85
+ receivedMetadata = metadata;
86
+ return okResponse({ job_id: "job-1" });
87
+ },
88
+ };
89
+
90
+ const upload = createUploadMethods(fakeTransport as Transport);
91
+ await upload.music(new Blob(["x"]), undefined, {
92
+ targetRemoteId: "remote-1",
93
+ targetRemoteName: "my server",
94
+ });
95
+
96
+ expect(receivedMetadata).toEqual({
97
+ target_remote_id: "remote-1",
98
+ target_remote_name: "my server",
99
+ });
100
+ // the send-target fields must NOT be smuggled into FormData anymore -
101
+ // only the file itself belongs there.
102
+ expect(receivedFormData!.get("target_remote_id")).toBeNull();
103
+ expect(receivedFormData!.get("target_remote_name")).toBeNull();
104
+ });
105
+
106
+ it("music() passes no metadata when no send target is given", async () => {
107
+ let receivedMetadata: UploadMetadata | undefined = { target_remote_id: "should-be-cleared" };
108
+ const fakeTransport: Pick<Transport, "upload"> = {
109
+ upload: async (_path, _formData, _onProgress, metadata) => {
110
+ receivedMetadata = metadata;
111
+ return okResponse({ job_id: "job-1" });
112
+ },
113
+ };
114
+
115
+ const upload = createUploadMethods(fakeTransport as Transport);
116
+ await upload.music(new Blob(["x"]));
117
+
118
+ expect(receivedMetadata).toBeUndefined();
119
+ });
120
+
121
+ it("image() passes associate_with as typed metadata, not a FormData field", async () => {
122
+ let receivedFormData: FormData | undefined;
123
+ let receivedMetadata: UploadMetadata | undefined;
124
+ const fakeTransport: Pick<Transport, "upload"> = {
125
+ upload: async (_path, formData, _onProgress, metadata) => {
126
+ receivedFormData = formData;
127
+ receivedMetadata = metadata;
128
+ return okResponse({ blob_id: "blob-1" });
129
+ },
130
+ };
131
+
132
+ const upload = createUploadMethods(fakeTransport as Transport);
133
+ const associate = {
134
+ entity_type: "album",
135
+ entity_id: "abc",
136
+ } as UploadMetadata["associate_with"];
137
+ await upload.image(new Blob(["x"]), { associate });
138
+
139
+ expect(receivedMetadata).toEqual({ associate_with: associate });
140
+ expect(receivedFormData!.get("associate_with")).toBeNull();
141
+ });
142
+ });