@freqhole/api-client 0.2.15 → 0.3.2
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 +70 -35
- package/src/CharnelTransport.ts +391 -106
- package/src/FreqholeClient.ts +46 -83
- package/src/WasmTransport.ts +265 -219
- package/src/blobCachePolicy.test.ts +199 -0
- package/src/codegen/routes.ts +75 -13
- package/src/codegen/schema.ts +1441 -225
- package/src/domains/entities.ts +304 -0
- package/src/domains/favorites.types.ts +13 -3
- package/src/domains/music.ts +74 -126
- package/src/domains/upload.ts +54 -53
- package/src/domains/video.ts +463 -0
- package/src/errors.ts +142 -0
- package/src/index.ts +47 -10
- package/src/permissions.ts +18 -36
- package/src/tauriRuntime.ts +22 -0
- package/src/test/fixtures.ts +13 -23
- package/src/test/integration.ts +15 -28
- package/src/test/stateful.ts +11 -29
- package/src/transport.ts +72 -39
package/package.json
CHANGED
|
@@ -5,12 +5,7 @@
|
|
|
5
5
|
// uses Tauri's asset protocol for blob/audio file access (no HTTP streaming).
|
|
6
6
|
|
|
7
7
|
import type { Transport, TransportResponse, BlobData } from "./transport.js";
|
|
8
|
-
import type {
|
|
9
|
-
CloseReason,
|
|
10
|
-
EventFilter,
|
|
11
|
-
JobEvent,
|
|
12
|
-
JobStateSnapshot,
|
|
13
|
-
} from "./codegen/schema.js";
|
|
8
|
+
import type { CloseReason, EventFilter, JobEvent, JobStateSnapshot } from "./codegen/schema.js";
|
|
14
9
|
|
|
15
10
|
// tauri invoke function type
|
|
16
11
|
type InvokeFn = (cmd: string, args?: unknown) => Promise<unknown>;
|
|
@@ -31,6 +26,16 @@ let convertFileSrc: ((path: string) => string) | null = null;
|
|
|
31
26
|
// blob: object url path below.
|
|
32
27
|
const isLinuxWebKit = typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
|
|
33
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
|
+
}
|
|
38
|
+
|
|
34
39
|
/**
|
|
35
40
|
* initialize tauri invoke function
|
|
36
41
|
*/
|
|
@@ -94,6 +99,13 @@ export class CharnelLocalTransport implements Transport {
|
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
// temporary diagnostic: log the exact payload sent over IPC for
|
|
103
|
+
// query-style routes, to rule out serialization loss between the
|
|
104
|
+
// caller and the tauri invoke() call (see tauri tag-filter bug).
|
|
105
|
+
if (path.includes("/query")) {
|
|
106
|
+
console.info(`[CharnelLocalTransport] -> ${path}`, jsonBody);
|
|
107
|
+
}
|
|
108
|
+
|
|
97
109
|
// temporary boot-timing instrumentation (see slow-tauri-boot investigation) —
|
|
98
110
|
// logs elapsed ms per IPC round-trip so a slow "loading..." screen can be
|
|
99
111
|
// narrowed down to a specific api call instead of guessing.
|
|
@@ -149,11 +161,22 @@ export class CharnelLocalTransport implements Transport {
|
|
|
149
161
|
|
|
150
162
|
/**
|
|
151
163
|
* upload - converts FormData to base64 JSON and calls dispatch
|
|
152
|
-
*
|
|
164
|
+
*
|
|
153
165
|
* for tauri-local, we use wait_for_completion to block until the job
|
|
154
166
|
* finishes, avoiding the need for polling from the client side.
|
|
155
167
|
*/
|
|
156
|
-
async upload(
|
|
168
|
+
async upload(
|
|
169
|
+
path: string,
|
|
170
|
+
formData: FormData,
|
|
171
|
+
_onProgress?: (loaded: number, total: number) => void,
|
|
172
|
+
): 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.
|
|
157
180
|
// extract file and other fields from FormData
|
|
158
181
|
const file = formData.get("file") as File | null;
|
|
159
182
|
if (!file) {
|
|
@@ -170,7 +193,7 @@ export class CharnelLocalTransport implements Transport {
|
|
|
170
193
|
// read file as base64
|
|
171
194
|
const arrayBuffer = await file.arrayBuffer();
|
|
172
195
|
const base64 = btoa(
|
|
173
|
-
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), "")
|
|
196
|
+
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), ""),
|
|
174
197
|
);
|
|
175
198
|
|
|
176
199
|
// build JSON body for dispatch
|
|
@@ -249,11 +272,13 @@ export class CharnelLocalTransport implements Transport {
|
|
|
249
272
|
this.blobPathCache.set(blobId, pathInfo);
|
|
250
273
|
}
|
|
251
274
|
}
|
|
252
|
-
|
|
275
|
+
|
|
253
276
|
// check for no_local_path error - fall back to /data endpoint
|
|
254
277
|
if (!pathInfo) {
|
|
255
278
|
const parsed = JSON.parse(response.body);
|
|
256
|
-
const isNoLocalPath = parsed.errors?.some(
|
|
279
|
+
const isNoLocalPath = parsed.errors?.some(
|
|
280
|
+
(e: { error_type: string }) => e.error_type === "no_local_path",
|
|
281
|
+
);
|
|
257
282
|
if (isNoLocalPath || response.status !== 200) {
|
|
258
283
|
return this.fetchBlobData(blobId);
|
|
259
284
|
}
|
|
@@ -285,26 +310,26 @@ export class CharnelLocalTransport implements Transport {
|
|
|
285
310
|
if (response.status !== 200) {
|
|
286
311
|
throw new Error(`failed to get blob data: ${response.body}`);
|
|
287
312
|
}
|
|
288
|
-
|
|
313
|
+
|
|
289
314
|
const parsed = JSON.parse(response.body);
|
|
290
315
|
if (!parsed.data?.data) {
|
|
291
316
|
throw new Error(`blob data not found: ${blobId}`);
|
|
292
317
|
}
|
|
293
|
-
|
|
318
|
+
|
|
294
319
|
// decode base64 to Uint8Array
|
|
295
320
|
const binaryString = atob(parsed.data.data);
|
|
296
321
|
const bytes = new Uint8Array(binaryString.length);
|
|
297
322
|
for (let i = 0; i < binaryString.length; i++) {
|
|
298
323
|
bytes[i] = binaryString.charCodeAt(i);
|
|
299
324
|
}
|
|
300
|
-
|
|
325
|
+
|
|
301
326
|
const mime = parsed.data.mime ?? "application/octet-stream";
|
|
302
|
-
|
|
327
|
+
|
|
303
328
|
// create and cache object URL for future use
|
|
304
329
|
const blob = new Blob([bytes], { type: mime });
|
|
305
330
|
const objectUrl = URL.createObjectURL(blob);
|
|
306
331
|
this.blobObjectUrlCache.set(blobId, objectUrl);
|
|
307
|
-
|
|
332
|
+
|
|
308
333
|
return {
|
|
309
334
|
data: bytes,
|
|
310
335
|
contentType: mime,
|
|
@@ -318,6 +343,10 @@ export class CharnelLocalTransport implements Transport {
|
|
|
318
343
|
* 3. linux fallback: fetch via asset:// and wrap in a blob: object URL
|
|
319
344
|
* (webkitgtk can't stream asset:// into `<audio>`)
|
|
320
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).
|
|
349
|
+
*
|
|
321
350
|
* note: when the rodio audio backend is enabled (charnel + opt-in)
|
|
322
351
|
* playback bypasses html `<audio>` entirely and reads files via
|
|
323
352
|
* filesystem path — this method is unused for that path.
|
|
@@ -331,14 +360,16 @@ export class CharnelLocalTransport implements Transport {
|
|
|
331
360
|
}
|
|
332
361
|
|
|
333
362
|
// on linux without rodio, we MUST go async to wrap in a blob:
|
|
334
|
-
// url (asset:// can't stream into <audio> on webkitgtk)
|
|
335
|
-
|
|
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)) {
|
|
336
367
|
console.debug(`[CharnelLocalTransport] blob ${blobId}: linux fallback (async)`);
|
|
337
368
|
return this.getBlobUrlAsync(blobId);
|
|
338
369
|
}
|
|
339
370
|
|
|
340
371
|
// check path cache (filesystem blobs) — direct asset:// url
|
|
341
|
-
const cached =
|
|
372
|
+
const cached = cachedPath;
|
|
342
373
|
if (cached && convertFileSrc) {
|
|
343
374
|
const url = convertFileSrc(cached.path);
|
|
344
375
|
console.debug(`[CharnelLocalTransport] blob ${blobId}: asset:// (cached) -> ${url}`);
|
|
@@ -358,7 +389,7 @@ export class CharnelLocalTransport implements Transport {
|
|
|
358
389
|
await ensureInvoke(); // ensure convertFileSrc is loaded
|
|
359
390
|
|
|
360
391
|
const response = await this.request("GET", `/api/blobs/${blobId}/path`, undefined);
|
|
361
|
-
|
|
392
|
+
|
|
362
393
|
if (response.status === 200) {
|
|
363
394
|
const parsed = JSON.parse(response.body);
|
|
364
395
|
if (parsed.data?.path) {
|
|
@@ -376,18 +407,24 @@ export class CharnelLocalTransport implements Transport {
|
|
|
376
407
|
// - other (images / waveforms / cover art): per-blob cache so
|
|
377
408
|
// multiple `<img>` and css `background-image` urls coexist
|
|
378
409
|
// across the playerbar, queue sidebar, etc.
|
|
379
|
-
|
|
410
|
+
// video is excluded - buffering it would hang the app.
|
|
411
|
+
if (isLinuxWebKit && !isStreamableMime(parsed.data.mime)) {
|
|
380
412
|
return this.createBlobObjectUrl(blobId, parsed.data.path, parsed.data.mime);
|
|
381
413
|
}
|
|
382
414
|
|
|
415
|
+
console.debug(
|
|
416
|
+
`[CharnelLocalTransport] blob ${blobId}: asset:// stream (mime=${parsed.data.mime ?? "?"})`,
|
|
417
|
+
);
|
|
383
418
|
return convertFileSrc(parsed.data.path);
|
|
384
419
|
}
|
|
385
420
|
}
|
|
386
|
-
|
|
421
|
+
|
|
387
422
|
// check for no_local_path - fall back to /data endpoint
|
|
388
423
|
const parsed = JSON.parse(response.body);
|
|
389
|
-
const isNoLocalPath = parsed.errors?.some(
|
|
390
|
-
|
|
424
|
+
const isNoLocalPath = parsed.errors?.some(
|
|
425
|
+
(e: { error_type: string }) => e.error_type === "no_local_path",
|
|
426
|
+
);
|
|
427
|
+
|
|
391
428
|
if (isNoLocalPath) {
|
|
392
429
|
// fetch blob data and create object URL
|
|
393
430
|
await this.fetchBlobData(blobId);
|
|
@@ -396,7 +433,7 @@ export class CharnelLocalTransport implements Transport {
|
|
|
396
433
|
return objectUrl;
|
|
397
434
|
}
|
|
398
435
|
}
|
|
399
|
-
|
|
436
|
+
|
|
400
437
|
throw new Error(`failed to get blob path: ${response.body}`);
|
|
401
438
|
}
|
|
402
439
|
|
|
@@ -415,7 +452,11 @@ export class CharnelLocalTransport implements Transport {
|
|
|
415
452
|
* css `background-image` references coexist without one
|
|
416
453
|
* revoking another.
|
|
417
454
|
*/
|
|
418
|
-
private async createBlobObjectUrl(
|
|
455
|
+
private async createBlobObjectUrl(
|
|
456
|
+
blobId: string,
|
|
457
|
+
localPath: string,
|
|
458
|
+
mime?: string,
|
|
459
|
+
): Promise<string> {
|
|
419
460
|
if (!convertFileSrc) {
|
|
420
461
|
throw new Error("convertFileSrc not available");
|
|
421
462
|
}
|
|
@@ -465,10 +506,7 @@ export class CharnelLocalTransport implements Transport {
|
|
|
465
506
|
return out;
|
|
466
507
|
}
|
|
467
508
|
|
|
468
|
-
subscribeJobEvents(
|
|
469
|
-
filter?: EventFilter,
|
|
470
|
-
signal?: AbortSignal,
|
|
471
|
-
): AsyncIterable<JobEvent> {
|
|
509
|
+
subscribeJobEvents(filter?: EventFilter, signal?: AbortSignal): AsyncIterable<JobEvent> {
|
|
472
510
|
return charnelJobEventsIterable(filter, signal);
|
|
473
511
|
}
|
|
474
512
|
}
|
|
@@ -509,9 +547,7 @@ export function createCharnelLocalTransport(baseUrl: string): CharnelLocalTransp
|
|
|
509
547
|
* `EventsServerMsg` from `events_protocol.rs` but flattened — no
|
|
510
548
|
* per-bistream correlation id (per-channel on tauri makes it unneeded).
|
|
511
549
|
*/
|
|
512
|
-
type JobsEventsFrame =
|
|
513
|
-
| { kind: "event"; evt: JobEvent }
|
|
514
|
-
| { kind: "closed"; reason: CloseReason };
|
|
550
|
+
type JobsEventsFrame = { kind: "event"; evt: JobEvent } | { kind: "closed"; reason: CloseReason };
|
|
515
551
|
|
|
516
552
|
async function* charnelJobEventsIterable(
|
|
517
553
|
filter: EventFilter | undefined,
|
|
@@ -574,8 +610,7 @@ async function* charnelJobEventsIterable(
|
|
|
574
610
|
// surface the close reason as a typed error so consumers can
|
|
575
611
|
// distinguish `Lagged` (re-snapshot + reconnect) from other
|
|
576
612
|
// terminal conditions.
|
|
577
|
-
const reasonKind =
|
|
578
|
-
(frame.reason as { kind: string }).kind ?? "internal";
|
|
613
|
+
const reasonKind = (frame.reason as { kind: string }).kind ?? "internal";
|
|
579
614
|
if (reasonKind === "client_unsubscribed") return;
|
|
580
615
|
throw new JobEventsStreamClosed(frame.reason);
|
|
581
616
|
}
|