@freqhole/playlistz 0.0.1

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.
Files changed (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. package/tsconfig.json +43 -0
@@ -0,0 +1,702 @@
1
+ // p2p blob transfer for playlistz (phase 6).
2
+ //
3
+ // docs carry sha256 hashes; bytes live in the shared blob store. when a
4
+ // blob is missing locally, this service fetches it from a doc's peers
5
+ // using iroh-blobs verified streaming:
6
+ //
7
+ // requester owner
8
+ // --------- -----
9
+ // open_bi(freqhole-playlistz/1)
10
+ // blob_request { sha256 } ----> getBlob(sha256) from blob store
11
+ // import_blob into iroh-blobs store
12
+ // blob_ready { blake3, size } <----
13
+ // download_verified_streaming(blake3) [iroh-blobs ALPN, rust-side]
14
+ // assemble chunks -> storeBlob
15
+ //
16
+ // the serving side keeps an import cache with a release timer so repeat
17
+ // requests skip the bao recomputation and memory is bounded.
18
+
19
+ import {
20
+ getBlob,
21
+ getBlobMetadata,
22
+ storeBlob,
23
+ } from "@freqhole/api-client/storage";
24
+ import { createSignal } from "solid-js";
25
+ import {
26
+ PLAYLISTZ_ALPN,
27
+ sendMessage,
28
+ readMessage,
29
+ type BiStreamLike,
30
+ } from "@freqhole/api-client/playlistz";
31
+ import type { AutomergeUrl } from "@automerge/automerge-repo";
32
+ import { getNode } from "./p2pService.js";
33
+ import { getIrohAdapter, findPlaylistDoc } from "./automergeRepo.js";
34
+ import { getIdentity } from "./p2pService.js";
35
+ import { getSongsForPlaylist } from "./playlistDocService.js";
36
+ import type { Playlist, Song } from "../types/playlist.js";
37
+
38
+ // midden node surface used here, beyond the stream interface declared in
39
+ // freqhole-api-client/automerge. structural cast - midden provides these.
40
+ interface BlobCapableNode {
41
+ node_id(): string;
42
+ open_bi(peer_addr: string, alpn: string): Promise<unknown>;
43
+ import_blob(data: Uint8Array): Promise<string>;
44
+ release_blob(blake3_hash: string): void;
45
+ download_verified_streaming(
46
+ peer_addr: string,
47
+ blake3_hash: string,
48
+ total_size: number,
49
+ on_chunk: (chunk: Uint8Array, offset: number) => void,
50
+ on_progress: (fraction: number) => void
51
+ ): Promise<number>;
52
+ }
53
+
54
+ function getBlobNode(): BlobCapableNode | null {
55
+ return getNode() as unknown as BlobCapableNode | null;
56
+ }
57
+
58
+ // --- serving side ---
59
+
60
+ // sha256 -> blake3 for blobs currently imported into the iroh-blobs store
61
+ const servedBlobs = new Map<
62
+ string,
63
+ { blake3: string; releaseTimer: ReturnType<typeof setTimeout> }
64
+ >();
65
+
66
+ // how long an imported blob stays available after the last request
67
+ const RELEASE_AFTER_MS = 10 * 60 * 1000;
68
+
69
+ // count of in-progress outbound serve requests (we are serving a blob to a peer)
70
+ let activeServes = 0;
71
+
72
+ function scheduleRelease(sha256: string, blake3: string): void {
73
+ const existing = servedBlobs.get(sha256);
74
+ if (existing) {
75
+ clearTimeout(existing.releaseTimer);
76
+ }
77
+ const releaseTimer = setTimeout(() => {
78
+ servedBlobs.delete(sha256);
79
+ try {
80
+ getBlobNode()?.release_blob(blake3);
81
+ } catch {
82
+ // node may be gone
83
+ }
84
+ }, RELEASE_AFTER_MS);
85
+ servedBlobs.set(sha256, { blake3, releaseTimer });
86
+ }
87
+
88
+ /**
89
+ * answer a blob_request on an open protocol stream: import the local
90
+ * blob into the iroh-blobs store and reply with its blake3 + size.
91
+ * called from the sharing service's stream handler.
92
+ */
93
+ export async function serveBlobRequest(
94
+ stream: BiStreamLike,
95
+ sha256: string
96
+ ): Promise<void> {
97
+ activeServes++;
98
+ notifyTransferListeners();
99
+ try {
100
+ await _serveBlobRequest(stream, sha256);
101
+ } finally {
102
+ activeServes--;
103
+ notifyTransferListeners();
104
+ }
105
+ }
106
+
107
+ async function _serveBlobRequest(
108
+ stream: BiStreamLike,
109
+ sha256: string
110
+ ): Promise<void> {
111
+ const node = getBlobNode();
112
+ if (!node) {
113
+ await sendMessage(stream, {
114
+ v: 1,
115
+ type: "error",
116
+ code: "no_node",
117
+ message: "p2p node is not running",
118
+ });
119
+ return;
120
+ }
121
+
122
+ const blob = await getBlob(sha256);
123
+ if (!blob) {
124
+ await sendMessage(stream, {
125
+ v: 1,
126
+ type: "error",
127
+ code: "blob_not_found",
128
+ message: `no blob with sha256 ${sha256}`,
129
+ });
130
+ return;
131
+ }
132
+
133
+ let blake3 = servedBlobs.get(sha256)?.blake3;
134
+ if (!blake3) {
135
+ const bytes = new Uint8Array(await blob.arrayBuffer());
136
+ blake3 = await node.import_blob(bytes);
137
+ }
138
+ scheduleRelease(sha256, blake3);
139
+
140
+ await sendMessage(stream, {
141
+ v: 1,
142
+ type: "blob_ready",
143
+ sha256,
144
+ blake3,
145
+ size: blob.size,
146
+ });
147
+ }
148
+
149
+ // --- per-sha download state (reactive) ---
150
+
151
+ export type BlobDownloadState = "downloading" | "pending" | "error";
152
+
153
+ // sha256 -> current download state for in-progress or failed fetches.
154
+ // absence = not currently tracked (either cached or not yet started).
155
+ const [_blobDownloadStates, _setBlobDownloadStates] = createSignal<
156
+ ReadonlyMap<string, BlobDownloadState>
157
+ >(new Map(), { equals: false });
158
+
159
+ export const blobDownloadStates = _blobDownloadStates;
160
+
161
+ function setBlobState(sha256: string, state: BlobDownloadState | null): void {
162
+ _setBlobDownloadStates((prev) => {
163
+ const next = new Map(prev);
164
+ if (state === null) next.delete(sha256);
165
+ else next.set(sha256, state);
166
+ return next;
167
+ });
168
+ }
169
+
170
+ // --- fetching side ---
171
+
172
+ export interface BlobFetchProgress {
173
+ sha256: string;
174
+ fraction: number; // 0..1
175
+ }
176
+
177
+ // max concurrent outbound playlistz streams per peer. QUIC peers can reject
178
+ // streams if too many are opened simultaneously - keep this conservative.
179
+ const MAX_CONCURRENT_STREAMS_PER_PEER = 2;
180
+
181
+ // per-peer active stream count + queued waiters
182
+ const peerStreamCounts = new Map<string, number>();
183
+ const peerStreamWaiters = new Map<string, Array<() => void>>();
184
+
185
+ function acquirePeerStream(peerNodeId: string): Promise<void> {
186
+ const count = peerStreamCounts.get(peerNodeId) ?? 0;
187
+ if (count < MAX_CONCURRENT_STREAMS_PER_PEER) {
188
+ peerStreamCounts.set(peerNodeId, count + 1);
189
+ return Promise.resolve();
190
+ }
191
+ return new Promise((resolve) => {
192
+ let waiters = peerStreamWaiters.get(peerNodeId);
193
+ if (!waiters) {
194
+ waiters = [];
195
+ peerStreamWaiters.set(peerNodeId, waiters);
196
+ }
197
+ waiters.push(resolve);
198
+ });
199
+ }
200
+
201
+ function releasePeerStream(peerNodeId: string): void {
202
+ const waiters = peerStreamWaiters.get(peerNodeId);
203
+ if (waiters && waiters.length > 0) {
204
+ const next = waiters.shift()!;
205
+ // count stays the same - the waiter takes the slot
206
+ next();
207
+ return;
208
+ }
209
+ const count = peerStreamCounts.get(peerNodeId) ?? 1;
210
+ peerStreamCounts.set(peerNodeId, Math.max(0, count - 1));
211
+ }
212
+
213
+ // in-flight fetches deduped by sha256
214
+ const inflight = new Map<string, Promise<string | null>>();
215
+
216
+ // timeout for individual blob fetches (configurable by dev hook)
217
+ let BLOB_FETCH_TIMEOUT_MS = 30_000;
218
+
219
+ export function _devSetBlobFetchTimeout(ms: number): void {
220
+ BLOB_FETCH_TIMEOUT_MS = ms;
221
+ }
222
+
223
+ // --- transfer count listeners (used by sharingState for ui signals) ---
224
+
225
+ const _transferListeners = new Set<() => void>();
226
+
227
+ function notifyTransferListeners(): void {
228
+ for (const cb of _transferListeners) {
229
+ try { cb(); } catch { /* ignore listener errors */ }
230
+ }
231
+ }
232
+
233
+ export function onTransferCountChange(cb: () => void): () => void {
234
+ _transferListeners.add(cb);
235
+ return () => _transferListeners.delete(cb);
236
+ }
237
+
238
+ export function getActiveTransferCount(): number {
239
+ return inflight.size + activeServes;
240
+ }
241
+
242
+ /** returns true if the blob with the given sha256 exists in the local blob store. */
243
+ export async function isBlobCachedLocally(
244
+ sha: string | undefined
245
+ ): Promise<boolean> {
246
+ if (!sha) return false;
247
+ return (await getBlobMetadata(sha)) !== null;
248
+ }
249
+
250
+ /**
251
+ * fetch a blob from a specific peer. returns the stored blobId (sha256)
252
+ * or null on failure.
253
+ */
254
+ async function fetchBlobFromPeer(
255
+ peerNodeId: string,
256
+ sha256: string,
257
+ mimeType: string,
258
+ onProgress?: (p: BlobFetchProgress) => void
259
+ ): Promise<string | null> {
260
+ const node = getBlobNode();
261
+ if (!node) return null;
262
+
263
+ let blake3: string;
264
+ let size: number;
265
+
266
+ // throttle concurrent streams to avoid overwhelming the QUIC connection
267
+ await acquirePeerStream(peerNodeId);
268
+ // ask the peer to stage the blob for verified download
269
+ const stream = (await node.open_bi(
270
+ peerNodeId,
271
+ PLAYLISTZ_ALPN
272
+ )) as BiStreamLike;
273
+ try {
274
+ await sendMessage(stream, { v: 1, type: "blob_request", sha256 });
275
+ const reply = await readMessage(stream);
276
+ if (reply?.type !== "blob_ready") {
277
+ return null;
278
+ }
279
+ blake3 = reply.blake3;
280
+ size = reply.size;
281
+ } finally {
282
+ stream.close();
283
+ releasePeerStream(peerNodeId);
284
+ }
285
+
286
+ // verified streaming download over the iroh-blobs ALPN
287
+ const parts: Uint8Array[] = [];
288
+ await node.download_verified_streaming(
289
+ peerNodeId,
290
+ blake3,
291
+ size,
292
+ (chunk) => {
293
+ // copy: the wasm-side buffer may be reused
294
+ parts.push(chunk.slice());
295
+ },
296
+ (fraction) => {
297
+ onProgress?.({ sha256, fraction });
298
+ }
299
+ );
300
+
301
+ const blob = new Blob(parts as BlobPart[], { type: mimeType });
302
+ const storedId = await storeBlob(blob, mimeType);
303
+ if (storedId !== sha256) {
304
+ console.warn(
305
+ "[blobs] stored blob hash mismatch: expected",
306
+ sha256,
307
+ "got",
308
+ storedId
309
+ );
310
+ }
311
+ return storedId;
312
+ }
313
+
314
+ /**
315
+ * fetch a blob from any peer recorded in a doc's peers map.
316
+ * tries currently-connected peers first. resolves to the blobId or null.
317
+ * deduplicates concurrent fetches of the same sha256.
318
+ */
319
+ export async function fetchBlobForDoc(
320
+ docId: string,
321
+ sha256: string,
322
+ mimeType: string,
323
+ onProgress?: (p: BlobFetchProgress) => void
324
+ ): Promise<string | null> {
325
+ // already local?
326
+ if (await getBlobMetadata(sha256)) return sha256;
327
+
328
+ const existing = inflight.get(sha256);
329
+ if (existing) return existing;
330
+
331
+ // dev override: bypass real p2p transport (set by dev-hooks.ts)
332
+ if (import.meta.env.DEV && _devFetchOverride) {
333
+ setBlobState(sha256, "downloading");
334
+ notifyTransferListeners();
335
+ const devTask = _devFetchOverride(sha256, mimeType, onProgress);
336
+ const withTimeout = new Promise<string | null>((_, reject) => {
337
+ const t = setTimeout(() => reject(new Error("blob fetch timeout")), BLOB_FETCH_TIMEOUT_MS);
338
+ devTask.finally(() => clearTimeout(t));
339
+ });
340
+ const task = Promise.race([devTask, withTimeout]).then(
341
+ (r) => {
342
+ inflight.delete(sha256);
343
+ _setBlobDownloadStates((prev) => {
344
+ if (prev.get(sha256) === "downloading") {
345
+ const next = new Map(prev);
346
+ next.delete(sha256);
347
+ return next;
348
+ }
349
+ return prev;
350
+ });
351
+ notifyTransferListeners();
352
+ return r as string | null;
353
+ },
354
+ (err: unknown) => {
355
+ inflight.delete(sha256);
356
+ setBlobState(sha256, "error");
357
+ notifyTransferListeners();
358
+ throw err;
359
+ }
360
+ );
361
+ inflight.set(sha256, task);
362
+ return task;
363
+ }
364
+
365
+ const task = (async () => {
366
+ const myNodeId = getIdentity()?.node_id ?? "";
367
+ let peers: string[] = [];
368
+ try {
369
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
370
+ const doc = handle.doc();
371
+ peers = Object.keys(doc?.peers ?? {}).filter(
372
+ (n) => n && n !== myNodeId
373
+ );
374
+ } catch {
375
+ return null;
376
+ }
377
+ if (peers.length === 0) return null;
378
+
379
+ // prefer peers with an active stream
380
+ const adapter = getIrohAdapter();
381
+ peers.sort((a, b) => {
382
+ const ca = adapter.isConnected(a) ? 0 : 1;
383
+ const cb = adapter.isConnected(b) ? 0 : 1;
384
+ return ca - cb;
385
+ });
386
+
387
+ for (const peer of peers) {
388
+ // try each peer up to 2 times with a short delay on first failure
389
+ for (let attempt = 0; attempt < 2; attempt++) {
390
+ try {
391
+ const result = await fetchBlobFromPeer(
392
+ peer,
393
+ sha256,
394
+ mimeType,
395
+ onProgress
396
+ );
397
+ if (result) return result;
398
+ break; // null result (peer doesn't have it) - no point retrying
399
+ } catch (err) {
400
+ if (attempt === 0) {
401
+ // brief pause before retry - transient QUIC stream errors often clear
402
+ await new Promise((r) => setTimeout(r, 500));
403
+ } else {
404
+ console.warn(
405
+ "[blobs] fetch from peer failed (giving up):",
406
+ peer.slice(0, 16),
407
+ err
408
+ );
409
+ }
410
+ }
411
+ }
412
+ }
413
+ return null;
414
+ })();
415
+
416
+ const withTimeout = new Promise<string | null>((_, reject) => {
417
+ const t = setTimeout(() => reject(new Error("blob fetch timeout")), BLOB_FETCH_TIMEOUT_MS);
418
+ task.finally(() => clearTimeout(t));
419
+ });
420
+ const racedTask = Promise.race([task, withTimeout]) as Promise<string | null>;
421
+ inflight.set(sha256, racedTask);
422
+ setBlobState(sha256, "downloading");
423
+ notifyTransferListeners();
424
+ try {
425
+ const result = await racedTask;
426
+ return result;
427
+ } catch {
428
+ setBlobState(sha256, "error");
429
+ return null;
430
+ } finally {
431
+ inflight.delete(sha256);
432
+ // clear downloading state on success (error state stays until next attempt)
433
+ _setBlobDownloadStates((prev) => {
434
+ if (prev.get(sha256) === "downloading") {
435
+ const next = new Map(prev);
436
+ next.delete(sha256);
437
+ return next;
438
+ }
439
+ return prev;
440
+ });
441
+ notifyTransferListeners();
442
+ }
443
+ }
444
+
445
+ /**
446
+ * fetch a song's audio blob from the peers of its playlist doc.
447
+ * song.playlistId is the docId for doc-backed songs.
448
+ */
449
+ export async function fetchSongBlob(
450
+ song: Song,
451
+ onProgress?: (p: BlobFetchProgress) => void
452
+ ): Promise<string | null> {
453
+ const sha = song.sha ?? song.sha256;
454
+ if (!sha || !song.playlistId) return null;
455
+ return fetchBlobForDoc(
456
+ song.playlistId,
457
+ sha,
458
+ song.mimeType || "audio/mpeg",
459
+ onProgress
460
+ );
461
+ }
462
+
463
+ // --- prefetch + save offline ---
464
+
465
+ // upcoming-playback prefetch window
466
+ const PREFETCH_WINDOW_SECONDS = 30 * 60;
467
+ const PREFETCH_CONCURRENCY = 3;
468
+
469
+ let prefetchRun = 0;
470
+
471
+ /**
472
+ * prefetch audio blobs for upcoming songs in a playlist, starting after
473
+ * the given song, until ~30 minutes of playback are locally available.
474
+ * currentSongRemaining: seconds left in the currently-playing song - this
475
+ * is included in the budget so the window is always relative to now, not
476
+ * the start of the next song.
477
+ * fire-and-forget; a new call cancels the previous run.
478
+ */
479
+ export function prefetchUpcoming(playlist: Playlist, currentSongId: string, currentSongRemaining = 0): void {
480
+ const run = ++prefetchRun;
481
+ void (async () => {
482
+ const songs = await getSongsForPlaylist(playlist.id).catch(
483
+ () => [] as Song[]
484
+ );
485
+ const startIdx = songs.findIndex((s) => s.id === currentSongId);
486
+ if (startIdx === -1) return;
487
+
488
+ // collect songs within the budget window that need fetching
489
+ let budget = PREFETCH_WINDOW_SECONDS - currentSongRemaining;
490
+ const toFetch: Song[] = [];
491
+ const pendingShas: string[] = [];
492
+
493
+ const clearPending = () => {
494
+ for (const sha of pendingShas) {
495
+ _setBlobDownloadStates((prev) => {
496
+ if (prev.get(sha) === "pending") {
497
+ const next = new Map(prev);
498
+ next.delete(sha);
499
+ return next;
500
+ }
501
+ return prev;
502
+ });
503
+ }
504
+ };
505
+
506
+ for (let i = startIdx + 1; i < songs.length && budget > 0; i++) {
507
+ if (run !== prefetchRun) {
508
+ clearPending();
509
+ return;
510
+ }
511
+ const song = songs[i]!;
512
+ budget -= song.duration || 0;
513
+ const sha = song.sha ?? song.sha256;
514
+ if (!sha) continue;
515
+ if (await getBlobMetadata(sha)) continue; // already local
516
+ setBlobState(sha, "pending");
517
+ pendingShas.push(sha);
518
+ toFetch.push(song);
519
+ }
520
+
521
+ // fetch in concurrent batches
522
+ for (let i = 0; i < toFetch.length; i += PREFETCH_CONCURRENCY) {
523
+ if (run !== prefetchRun) {
524
+ clearPending();
525
+ return;
526
+ }
527
+ const batch = toFetch.slice(i, i + PREFETCH_CONCURRENCY);
528
+ await Promise.allSettled(batch.map((s) => fetchSongBlob(s)));
529
+ }
530
+
531
+ // clear any remaining pending states after normal completion
532
+ clearPending();
533
+ })();
534
+ }
535
+
536
+ export interface OfflineProgress {
537
+ done: number;
538
+ total: number;
539
+ currentTitle: string;
540
+ fraction: number; // overall 0..1
541
+ }
542
+
543
+ /**
544
+ * fetch every missing blob (audio + images) for a playlist so it can
545
+ * play fully offline. sequential, with per-item progress callbacks.
546
+ * returns the number of blobs fetched (0 = everything was local).
547
+ */
548
+ export async function savePlaylistOffline(
549
+ playlist: Playlist,
550
+ onProgress?: (p: OfflineProgress) => void
551
+ ): Promise<number> {
552
+ const docId = playlist.id;
553
+ const missing = await collectMissingBlobs(playlist);
554
+
555
+ let fetched = 0;
556
+ for (let i = 0; i < missing.length; i++) {
557
+ const item = missing[i]!;
558
+ onProgress?.({
559
+ done: i,
560
+ total: missing.length,
561
+ currentTitle: item.title,
562
+ fraction: missing.length === 0 ? 1 : i / missing.length,
563
+ });
564
+ const result = await fetchBlobForDoc(docId, item.sha, item.mime, (p) => {
565
+ onProgress?.({
566
+ done: i,
567
+ total: missing.length,
568
+ currentTitle: item.title,
569
+ fraction: (i + p.fraction) / missing.length,
570
+ });
571
+ });
572
+ if (result) fetched++;
573
+ }
574
+
575
+ onProgress?.({
576
+ done: missing.length,
577
+ total: missing.length,
578
+ currentTitle: "",
579
+ fraction: 1,
580
+ });
581
+ return fetched;
582
+ }
583
+
584
+ /**
585
+ * true when any blob the playlist references (audio or images) is not
586
+ * yet in the local blob store. used to hide "save offline" once a
587
+ * playlist is fully cached.
588
+ */
589
+ export async function playlistHasMissingBlobs(
590
+ playlist: Playlist
591
+ ): Promise<boolean> {
592
+ const missing = await collectMissingBlobs(playlist);
593
+ return missing.length > 0;
594
+ }
595
+
596
+ // gather every blob a playlist references (song audio, song images,
597
+ // playlist covers), deduped, and return the subset missing locally.
598
+ // cover images come first so the playlist looks good as soon as possible.
599
+ async function collectMissingBlobs(
600
+ playlist: Playlist
601
+ ): Promise<{ sha: string; mime: string; title: string }[]> {
602
+ const docId = playlist.id;
603
+ const coverItems: { sha: string; mime: string; title: string }[] = [];
604
+ const audioItems: { sha: string; mime: string; title: string }[] = [];
605
+ const imageItems: { sha: string; mime: string; title: string }[] = [];
606
+
607
+ const songs = await getSongsForPlaylist(docId).catch(() => [] as Song[]);
608
+ for (const song of songs) {
609
+ const sha = song.sha ?? song.sha256;
610
+ if (sha) {
611
+ audioItems.push({
612
+ sha,
613
+ mime: song.mimeType || "audio/mpeg",
614
+ title: song.title,
615
+ });
616
+ }
617
+ for (const img of song.images ?? []) {
618
+ if (img.blobId) {
619
+ imageItems.push({
620
+ sha: img.blobId,
621
+ mime: "image/jpeg",
622
+ title: `${song.title} (image)`,
623
+ });
624
+ }
625
+ }
626
+ }
627
+
628
+ // playlist cover images - fetched before song audio for fast visual loading
629
+ try {
630
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
631
+ const doc = handle.doc();
632
+ for (const img of doc?.images ?? []) {
633
+ if (img.blobId) {
634
+ coverItems.push({
635
+ sha: img.blobId,
636
+ mime: "image/jpeg",
637
+ title: "playlist cover",
638
+ });
639
+ }
640
+ }
641
+ } catch {
642
+ // doc unavailable - song list already covers most blobs
643
+ }
644
+
645
+ // dedupe: covers → song images → audio
646
+ const wanted = [...coverItems, ...imageItems, ...audioItems];
647
+ const seen = new Set<string>();
648
+ const missing: typeof wanted = [];
649
+ for (const item of wanted) {
650
+ if (seen.has(item.sha)) continue;
651
+ seen.add(item.sha);
652
+ if (!(await getBlobMetadata(item.sha))) {
653
+ missing.push(item);
654
+ }
655
+ }
656
+ return missing;
657
+ }
658
+
659
+ /** reset module state. for use in tests only. */
660
+ export function _resetBlobTransferForTests(): void {
661
+ for (const { releaseTimer } of servedBlobs.values()) {
662
+ clearTimeout(releaseTimer);
663
+ }
664
+ servedBlobs.clear();
665
+ inflight.clear();
666
+ _setBlobDownloadStates(new Map());
667
+ prefetchRun++;
668
+ _devFetchOverride = null;
669
+ BLOB_FETCH_TIMEOUT_MS = 30_000;
670
+ }
671
+
672
+ // --- dev hook slot (implementation lives in src/dev-hooks.ts) ---
673
+
674
+ // override function for fetchBlobForDoc - set by dev-hooks.ts in DEV builds only.
675
+ // checked under `import.meta.env.DEV` so the branch is eliminated in production.
676
+ let _devFetchOverride: (
677
+ | ((
678
+ sha256: string,
679
+ mimeType: string,
680
+ onProgress?: (p: BlobFetchProgress) => void
681
+ ) => Promise<string | null>)
682
+ | null
683
+ ) = null;
684
+
685
+ // set the fetch override (called from dev-hooks.ts)
686
+ export function _devSetFetchOverride(
687
+ fn: typeof _devFetchOverride
688
+ ): void {
689
+ _devFetchOverride = fn;
690
+ }
691
+
692
+ // evict a blob from local store - for simulating cache misses in tests
693
+ export async function _devEvictBlob(sha256: string): Promise<void> {
694
+ const { deleteBlob } = await import("@freqhole/api-client/storage");
695
+ await deleteBlob(sha256).catch(() => {});
696
+ }
697
+
698
+ // fetch a blob directly by sha256 - used in tests to trigger retry without a UI click.
699
+ // passes an empty docId because mock overrides don't use it.
700
+ export async function _devFetchBlobBySha(sha256: string): Promise<string | null> {
701
+ return fetchBlobForDoc("", sha256, "audio/wav");
702
+ }