@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,441 @@
1
+ // tests for p2p blob transfer (phase 6).
2
+ //
3
+ // mocks the midden node (import_blob / download_verified_streaming),
4
+ // the iroh adapter, doc lookups, and the blob store. the serving and
5
+ // fetching sides are exercised against scripted protocol streams.
6
+
7
+ import { describe, it, expect, beforeEach, vi } from "vitest";
8
+ import {
9
+ PLAYLISTZ_ALPN,
10
+ encodeMessage,
11
+ decodeMessage,
12
+ type Message,
13
+ type BiStreamLike,
14
+ } from "@freqhole/api-client/playlistz";
15
+ import type { Playlist, Song } from "../types/playlist.js";
16
+
17
+ // --- mocks (hoisted before module imports) ---
18
+
19
+ const { docs, songLists, adapter, p2p, blobStore } = vi.hoisted(() => {
20
+ const docs = new Map<string, Record<string, unknown>>();
21
+ // docId -> songs returned by the mocked playlistDocService
22
+ const songLists = new Map<string, unknown[]>();
23
+ const adapter = {
24
+ isConnected: vi.fn(() => true),
25
+ };
26
+ const p2p = {
27
+ getNode: vi.fn((): unknown => null),
28
+ getIdentity: vi.fn(() => ({ node_id: "me-node" })),
29
+ };
30
+ // sha256 -> stored byte length. storeBlob derives the id from the
31
+ // blob size so verified downloads land on predictable ids
32
+ const blobStore = new Map<string, number>();
33
+ return { docs, songLists, adapter, p2p, blobStore };
34
+ });
35
+
36
+ vi.mock("./automergeRepo.js", () => ({
37
+ getIrohAdapter: () => adapter,
38
+ findPlaylistDoc: vi.fn(async (docId: string) => {
39
+ const doc = docs.get(docId);
40
+ if (!doc) throw new Error(`doc not found: ${docId}`);
41
+ return { doc: () => doc };
42
+ }),
43
+ }));
44
+
45
+ vi.mock("./p2pService.js", () => ({
46
+ getNode: p2p.getNode,
47
+ getIdentity: p2p.getIdentity,
48
+ }));
49
+
50
+ vi.mock("./playlistDocService.js", () => ({
51
+ getSongsForPlaylist: vi.fn(
52
+ async (docId: string) => songLists.get(docId) ?? []
53
+ ),
54
+ }));
55
+
56
+ vi.mock("@freqhole/api-client/storage", () => ({
57
+ storeBlob: vi.fn(async (blob: Blob) => {
58
+ const id = `mock-${blob.size}`;
59
+ blobStore.set(id, blob.size);
60
+ return id;
61
+ }),
62
+ getBlob: vi.fn(async (id: string) => {
63
+ const size = blobStore.get(id);
64
+ if (size === undefined) return null;
65
+ const bytes = new Uint8Array(size);
66
+ return {
67
+ size,
68
+ arrayBuffer: async () => bytes.buffer,
69
+ } as unknown as Blob;
70
+ }),
71
+ getBlobMetadata: vi.fn(async (id: string) => {
72
+ const size = blobStore.get(id);
73
+ if (size === undefined) return null;
74
+ return { blob_id: id, file_size: size };
75
+ }),
76
+ }));
77
+
78
+ import {
79
+ serveBlobRequest,
80
+ fetchBlobForDoc,
81
+ fetchSongBlob,
82
+ prefetchUpcoming,
83
+ savePlaylistOffline,
84
+ type OfflineProgress,
85
+ _resetBlobTransferForTests,
86
+ } from "./blobTransferService.js";
87
+
88
+ const DOC_ID = "automerge:doc1";
89
+
90
+ // stream used to test the serving side: collects replies
91
+ class CollectingStream implements BiStreamLike {
92
+ sent: Message[] = [];
93
+ async write_message(data: Uint8Array): Promise<void> {
94
+ this.sent.push(decodeMessage(data));
95
+ }
96
+ async read_message(): Promise<Uint8Array | null> {
97
+ return null;
98
+ }
99
+ close(): void {}
100
+ peer_node_id(): string {
101
+ return "peer-a";
102
+ }
103
+ alpn(): string {
104
+ return PLAYLISTZ_ALPN;
105
+ }
106
+ }
107
+
108
+ // stream used to test the fetching side: answers blob_request from a
109
+ // table of sha256 -> { blake3, size }
110
+ function makeServingStream(
111
+ table: Record<string, { blake3: string; size: number }>
112
+ ): BiStreamLike & { closed: boolean } {
113
+ const replies: Message[] = [];
114
+ return {
115
+ closed: false,
116
+ async write_message(data: Uint8Array) {
117
+ const msg = decodeMessage(data);
118
+ if (msg.type === "blob_request") {
119
+ const entry = table[msg.sha256];
120
+ replies.push(
121
+ entry
122
+ ? { v: 1, type: "blob_ready", sha256: msg.sha256, ...entry }
123
+ : {
124
+ v: 1,
125
+ type: "error",
126
+ code: "blob_not_found",
127
+ message: "nope",
128
+ }
129
+ );
130
+ }
131
+ },
132
+ async read_message() {
133
+ const msg = replies.shift();
134
+ return msg === undefined ? null : encodeMessage(msg);
135
+ },
136
+ close() {
137
+ this.closed = true;
138
+ },
139
+ peer_node_id: () => "peer-a",
140
+ alpn: () => PLAYLISTZ_ALPN,
141
+ };
142
+ }
143
+
144
+ // midden node mock: open_bi serves from the table, verified download
145
+ // streams `size` zero bytes in one chunk
146
+ function makeNode(table: Record<string, { blake3: string; size: number }>) {
147
+ return {
148
+ node_id: () => "me-node",
149
+ open_bi: vi.fn(async () => makeServingStream(table)),
150
+ import_blob: vi.fn(async () => "blake3-imported"),
151
+ release_blob: vi.fn(),
152
+ download_verified_streaming: vi.fn(
153
+ async (
154
+ _peer: string,
155
+ _hash: string,
156
+ size: number,
157
+ onChunk: (chunk: Uint8Array, offset: number) => void,
158
+ onProgress: (fraction: number) => void
159
+ ) => {
160
+ onChunk(new Uint8Array(size), 0);
161
+ onProgress(1);
162
+ return size;
163
+ }
164
+ ),
165
+ };
166
+ }
167
+
168
+ function makeSong(overrides: Partial<Song>): Song {
169
+ return {
170
+ id: "song-1",
171
+ playlistId: DOC_ID,
172
+ title: "track",
173
+ artist: "",
174
+ album: "",
175
+ duration: 60,
176
+ position: 0,
177
+ mimeType: "audio/mpeg",
178
+ originalFilename: "track.mp3",
179
+ fileSize: 0,
180
+ createdAt: 0,
181
+ updatedAt: 0,
182
+ ...overrides,
183
+ } as Song;
184
+ }
185
+
186
+ function makePlaylist(songs: Song[]): Playlist {
187
+ songLists.set(DOC_ID, songs);
188
+ return {
189
+ id: DOC_ID,
190
+ title: "tunez",
191
+ songIds: songs.map((s) => s.id),
192
+ createdAt: 0,
193
+ updatedAt: 0,
194
+ } as unknown as Playlist;
195
+ }
196
+
197
+ describe("blobTransferService", () => {
198
+ beforeEach(() => {
199
+ _resetBlobTransferForTests();
200
+ docs.clear();
201
+ songLists.clear();
202
+ blobStore.clear();
203
+ vi.clearAllMocks();
204
+ adapter.isConnected.mockReturnValue(true);
205
+ p2p.getIdentity.mockReturnValue({ node_id: "me-node" });
206
+ p2p.getNode.mockReturnValue(null);
207
+ docs.set(DOC_ID, { peers: { "me-node": {}, "peer-a": {} } });
208
+ });
209
+
210
+ describe("serveBlobRequest", () => {
211
+ it("replies no_node when the node is not running", async () => {
212
+ const stream = new CollectingStream();
213
+ await serveBlobRequest(stream, "mock-4");
214
+ expect(stream.sent[0]).toMatchObject({
215
+ type: "error",
216
+ code: "no_node",
217
+ });
218
+ });
219
+
220
+ it("replies blob_not_found for unknown blobs", async () => {
221
+ p2p.getNode.mockReturnValue(makeNode({}));
222
+ const stream = new CollectingStream();
223
+ await serveBlobRequest(stream, "mock-404");
224
+ expect(stream.sent[0]).toMatchObject({
225
+ type: "error",
226
+ code: "blob_not_found",
227
+ });
228
+ });
229
+
230
+ it("imports the blob and replies blob_ready", async () => {
231
+ const node = makeNode({});
232
+ p2p.getNode.mockReturnValue(node);
233
+ blobStore.set("mock-4", 4);
234
+ const stream = new CollectingStream();
235
+
236
+ await serveBlobRequest(stream, "mock-4");
237
+
238
+ expect(node.import_blob).toHaveBeenCalledTimes(1);
239
+ expect(stream.sent[0]).toEqual({
240
+ v: 1,
241
+ type: "blob_ready",
242
+ sha256: "mock-4",
243
+ blake3: "blake3-imported",
244
+ size: 4,
245
+ });
246
+ });
247
+
248
+ it("reuses the imported blob on repeat requests", async () => {
249
+ const node = makeNode({});
250
+ p2p.getNode.mockReturnValue(node);
251
+ blobStore.set("mock-4", 4);
252
+
253
+ await serveBlobRequest(new CollectingStream(), "mock-4");
254
+ await serveBlobRequest(new CollectingStream(), "mock-4");
255
+
256
+ expect(node.import_blob).toHaveBeenCalledTimes(1);
257
+ });
258
+ });
259
+
260
+ describe("fetchBlobForDoc", () => {
261
+ it("short-circuits when the blob is already local", async () => {
262
+ const node = makeNode({});
263
+ p2p.getNode.mockReturnValue(node);
264
+ blobStore.set("mock-4", 4);
265
+
266
+ const result = await fetchBlobForDoc(DOC_ID, "mock-4", "audio/mpeg");
267
+
268
+ expect(result).toBe("mock-4");
269
+ expect(node.open_bi).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it("returns null when the doc has no other peers", async () => {
273
+ docs.set(DOC_ID, { peers: { "me-node": {} } });
274
+ p2p.getNode.mockReturnValue(makeNode({}));
275
+
276
+ expect(await fetchBlobForDoc(DOC_ID, "mock-4", "audio/mpeg")).toBeNull();
277
+ });
278
+
279
+ it("fetches a missing blob from a doc peer and stores it", async () => {
280
+ const node = makeNode({ "mock-4": { blake3: "b3", size: 4 } });
281
+ p2p.getNode.mockReturnValue(node);
282
+ const fractions: number[] = [];
283
+
284
+ const result = await fetchBlobForDoc(
285
+ DOC_ID,
286
+ "mock-4",
287
+ "audio/mpeg",
288
+ (p) => fractions.push(p.fraction)
289
+ );
290
+
291
+ expect(result).toBe("mock-4");
292
+ expect(blobStore.has("mock-4")).toBe(true);
293
+ expect(node.open_bi).toHaveBeenCalledWith("peer-a", PLAYLISTZ_ALPN);
294
+ expect(node.download_verified_streaming).toHaveBeenCalledWith(
295
+ "peer-a",
296
+ "b3",
297
+ 4,
298
+ expect.any(Function),
299
+ expect.any(Function)
300
+ );
301
+ expect(fractions).toEqual([1]);
302
+ });
303
+
304
+ it("returns null when the peer does not have the blob", async () => {
305
+ p2p.getNode.mockReturnValue(makeNode({}));
306
+
307
+ expect(await fetchBlobForDoc(DOC_ID, "mock-4", "audio/mpeg")).toBeNull();
308
+ });
309
+
310
+ it("dedupes concurrent fetches of the same sha", async () => {
311
+ const node = makeNode({ "mock-4": { blake3: "b3", size: 4 } });
312
+ p2p.getNode.mockReturnValue(node);
313
+
314
+ const [a, b] = await Promise.all([
315
+ fetchBlobForDoc(DOC_ID, "mock-4", "audio/mpeg"),
316
+ fetchBlobForDoc(DOC_ID, "mock-4", "audio/mpeg"),
317
+ ]);
318
+
319
+ expect(a).toBe("mock-4");
320
+ expect(b).toBe("mock-4");
321
+ expect(node.open_bi).toHaveBeenCalledTimes(1);
322
+ });
323
+ });
324
+
325
+ describe("fetchSongBlob", () => {
326
+ it("returns null without a sha or playlist", async () => {
327
+ expect(await fetchSongBlob(makeSong({ sha: undefined }))).toBeNull();
328
+ expect(
329
+ await fetchSongBlob(makeSong({ sha: "x", playlistId: undefined }))
330
+ ).toBeNull();
331
+ });
332
+
333
+ it("falls back from sha to sha256", async () => {
334
+ const node = makeNode({ "mock-4": { blake3: "b3", size: 4 } });
335
+ p2p.getNode.mockReturnValue(node);
336
+
337
+ const result = await fetchSongBlob(
338
+ makeSong({ sha: undefined, sha256: "mock-4" })
339
+ );
340
+
341
+ expect(result).toBe("mock-4");
342
+ });
343
+ });
344
+
345
+ describe("prefetchUpcoming", () => {
346
+ it("fetches missing blobs for songs after the current one", async () => {
347
+ const node = makeNode({ "mock-7": { blake3: "b3", size: 7 } });
348
+ p2p.getNode.mockReturnValue(node);
349
+ blobStore.set("mock-4", 4); // song c is already local
350
+ const playlist = makePlaylist([
351
+ makeSong({ id: "a", sha: "mock-2" }),
352
+ makeSong({ id: "b", sha: "mock-7" }),
353
+ makeSong({ id: "c", sha: "mock-4" }),
354
+ ]);
355
+
356
+ prefetchUpcoming(playlist, "a");
357
+
358
+ await vi.waitFor(() => {
359
+ expect(blobStore.has("mock-7")).toBe(true);
360
+ });
361
+ // current song (a) and local song (c) were not fetched
362
+ expect(node.open_bi).toHaveBeenCalledTimes(1);
363
+ });
364
+
365
+ it("does nothing when the current song is not in the playlist", async () => {
366
+ const node = makeNode({});
367
+ p2p.getNode.mockReturnValue(node);
368
+ const playlist = makePlaylist([makeSong({ id: "a", sha: "mock-2" })]);
369
+
370
+ prefetchUpcoming(playlist, "nope");
371
+ await new Promise((r) => setTimeout(r, 10));
372
+
373
+ expect(node.open_bi).not.toHaveBeenCalled();
374
+ });
375
+ });
376
+
377
+ describe("savePlaylistOffline", () => {
378
+ it("fetches every missing blob and reports progress", async () => {
379
+ const node = makeNode({
380
+ "mock-7": { blake3: "b3a", size: 7 },
381
+ "mock-9": { blake3: "b3b", size: 9 },
382
+ });
383
+ p2p.getNode.mockReturnValue(node);
384
+ blobStore.set("mock-4", 4); // already local
385
+ const playlist = makePlaylist([
386
+ makeSong({ id: "a", sha: "mock-7" }),
387
+ makeSong({ id: "b", sha: "mock-4" }),
388
+ makeSong({ id: "c", sha: "mock-9" }),
389
+ ]);
390
+ const updates: OfflineProgress[] = [];
391
+
392
+ const fetched = await savePlaylistOffline(playlist, (p) =>
393
+ updates.push(p)
394
+ );
395
+
396
+ expect(fetched).toBe(2);
397
+ expect(blobStore.has("mock-7")).toBe(true);
398
+ expect(blobStore.has("mock-9")).toBe(true);
399
+ const last = updates[updates.length - 1]!;
400
+ expect(last).toMatchObject({ done: 2, total: 2, fraction: 1 });
401
+ });
402
+
403
+ it("includes song and playlist cover images", async () => {
404
+ const node = makeNode({
405
+ "mock-7": { blake3: "b3a", size: 7 },
406
+ "mock-11": { blake3: "b3b", size: 11 },
407
+ "mock-13": { blake3: "b3c", size: 13 },
408
+ });
409
+ p2p.getNode.mockReturnValue(node);
410
+ docs.set(DOC_ID, {
411
+ peers: { "peer-a": {} },
412
+ images: [{ blobId: "mock-13" }],
413
+ });
414
+ const playlist = makePlaylist([
415
+ makeSong({
416
+ id: "a",
417
+ sha: "mock-7",
418
+ images: [{ blobId: "mock-11" }] as Song["images"],
419
+ }),
420
+ ]);
421
+
422
+ const fetched = await savePlaylistOffline(playlist);
423
+
424
+ expect(fetched).toBe(3);
425
+ expect(blobStore.has("mock-11")).toBe(true);
426
+ expect(blobStore.has("mock-13")).toBe(true);
427
+ });
428
+
429
+ it("returns 0 when everything is already local", async () => {
430
+ const node = makeNode({});
431
+ p2p.getNode.mockReturnValue(node);
432
+ blobStore.set("mock-7", 7);
433
+ const playlist = makePlaylist([makeSong({ id: "a", sha: "mock-7" })]);
434
+
435
+ const fetched = await savePlaylistOffline(playlist);
436
+
437
+ expect(fetched).toBe(0);
438
+ expect(node.open_bi).not.toHaveBeenCalled();
439
+ });
440
+ });
441
+ });