@freqhole/api-client 0.3.4 → 0.3.6
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 +1 -1
- package/src/CharnelLocalTransport.ts +117 -41
- package/src/CharnelTransport.ts +15 -26
- package/src/WasmTransport.ts +106 -63
- package/src/base64.ts +16 -0
- package/src/codegen/routes.ts +2 -0
- package/src/codegen/schema.ts +62 -6
- package/src/domains/music.ts +15 -0
- package/src/domains/upload.ts +63 -7
- package/src/domains/video.ts +16 -0
- package/src/transport.ts +43 -1
- package/src/uploadMetadata.test.ts +142 -0
package/package.json
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
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>;
|
|
@@ -13,9 +14,6 @@ type InvokeFn = (cmd: string, args?: unknown) => Promise<unknown>;
|
|
|
13
14
|
// tauri invoke is dynamically imported to avoid bundling in browser builds
|
|
14
15
|
let invoke: InvokeFn | null = null;
|
|
15
16
|
let convertFileSrc: ((path: string, protocol?: string) => string) | null = null;
|
|
16
|
-
// cached target_os check (android only, for now) - see mediaSrcFor() below.
|
|
17
|
-
// null = not yet determined, false = determined non-android.
|
|
18
|
-
let isAndroidCached: boolean | null = null;
|
|
19
17
|
|
|
20
18
|
/**
|
|
21
19
|
* initialize tauri invoke function
|
|
@@ -27,12 +25,6 @@ async function ensureInvoke(): Promise<InvokeFn> {
|
|
|
27
25
|
invoke = tauri.invoke as InvokeFn;
|
|
28
26
|
// also grab convertFileSrc for blob URLs
|
|
29
27
|
convertFileSrc = tauri.convertFileSrc;
|
|
30
|
-
try {
|
|
31
|
-
const buildInfo = (await invoke("get_build_info")) as { target_os?: string };
|
|
32
|
-
isAndroidCached = buildInfo?.target_os === "android";
|
|
33
|
-
} catch {
|
|
34
|
-
isAndroidCached = false;
|
|
35
|
-
}
|
|
36
28
|
return invoke;
|
|
37
29
|
} catch {
|
|
38
30
|
throw new Error("@tauri-apps/api not available - not running in Tauri");
|
|
@@ -40,20 +32,35 @@ async function ensureInvoke(): Promise<InvokeFn> {
|
|
|
40
32
|
}
|
|
41
33
|
|
|
42
34
|
/**
|
|
43
|
-
* build a playable url for a local file path
|
|
44
|
-
* protocol
|
|
45
|
-
* `
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* from for
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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.
|
|
51
46
|
*/
|
|
52
47
|
function mediaSrcFor(path: string): string {
|
|
53
48
|
if (!convertFileSrc) {
|
|
54
49
|
throw new Error("convertFileSrc not available");
|
|
55
50
|
}
|
|
56
|
-
return
|
|
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";
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
/**
|
|
@@ -161,18 +168,22 @@ export class CharnelLocalTransport implements Transport {
|
|
|
161
168
|
console.info(
|
|
162
169
|
`[perf] api_call ${path} failed after ${(performance.now() - requestStart).toFixed(1)}ms`,
|
|
163
170
|
);
|
|
164
|
-
// IPC 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.
|
|
165
174
|
return {
|
|
166
175
|
status: 0,
|
|
167
176
|
body: JSON.stringify({
|
|
168
|
-
error: err
|
|
177
|
+
error: ipcErrorMessage(err),
|
|
169
178
|
}),
|
|
170
179
|
};
|
|
171
180
|
}
|
|
172
181
|
}
|
|
173
182
|
|
|
174
183
|
/**
|
|
175
|
-
* upload -
|
|
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.
|
|
176
187
|
*
|
|
177
188
|
* for tauri-local, we use wait_for_completion to block until the job
|
|
178
189
|
* finishes, avoiding the need for polling from the client side.
|
|
@@ -180,16 +191,9 @@ export class CharnelLocalTransport implements Transport {
|
|
|
180
191
|
async upload(
|
|
181
192
|
path: string,
|
|
182
193
|
formData: FormData,
|
|
183
|
-
|
|
194
|
+
onProgress?: (loaded: number, total: number) => void,
|
|
195
|
+
metadata?: UploadMetadata,
|
|
184
196
|
): Promise<TransportResponse> {
|
|
185
|
-
// no byte-level progress possible here - unlike CharnelTransport (P2P),
|
|
186
|
-
// which already chunks the file for android/memory reasons and can
|
|
187
|
-
// report progress per chunk, this local-dispatch path reads the whole
|
|
188
|
-
// file into one base64 JSON body and sends it as a single IPC call.
|
|
189
|
-
// chunking this too would need a new local (non-P2P) equivalent of the
|
|
190
|
-
// `-by-blake3` upload route, since that route currently requires a
|
|
191
|
-
// `node_id` and always pulls from a remote peer.
|
|
192
|
-
// extract file and other fields from FormData
|
|
193
197
|
const file = formData.get("file") as File | null;
|
|
194
198
|
if (!file) {
|
|
195
199
|
return {
|
|
@@ -202,6 +206,87 @@ export class CharnelLocalTransport implements Transport {
|
|
|
202
206
|
};
|
|
203
207
|
}
|
|
204
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> {
|
|
205
290
|
// read file as base64
|
|
206
291
|
const arrayBuffer = await file.arrayBuffer();
|
|
207
292
|
const base64 = btoa(
|
|
@@ -215,18 +300,9 @@ export class CharnelLocalTransport implements Transport {
|
|
|
215
300
|
// tauri-local optimization: wait for job to complete instead of returning job_id
|
|
216
301
|
// this eliminates the need for client-side polling
|
|
217
302
|
wait_for_completion: true,
|
|
303
|
+
...metadata,
|
|
218
304
|
};
|
|
219
305
|
|
|
220
|
-
// include associate_with if present
|
|
221
|
-
const associationStr = formData.get("associate_with");
|
|
222
|
-
if (associationStr && typeof associationStr === "string") {
|
|
223
|
-
try {
|
|
224
|
-
body.associate_with = JSON.parse(associationStr);
|
|
225
|
-
} catch {
|
|
226
|
-
// ignore parse errors
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
306
|
// call through normal request path
|
|
231
307
|
return this.request("POST", path, JSON.stringify(body));
|
|
232
308
|
}
|
package/src/CharnelTransport.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
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
|
);
|
package/src/WasmTransport.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
853
|
+
if (!blake3 && this.node.download_verified_by_id_progress) {
|
|
820
854
|
try {
|
|
821
|
-
|
|
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
|
+
}
|
package/src/codegen/routes.ts
CHANGED
|
@@ -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 },
|
package/src/codegen/schema.ts
CHANGED
|
@@ -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
|
|
package/src/domains/music.ts
CHANGED
|
@@ -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",
|
package/src/domains/upload.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
package/src/domains/video.ts
CHANGED
|
@@ -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/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 {
|
|
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
|
+
});
|