@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,275 @@
1
+ // tests for the streaming audio service (blob-store backed).
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import type { Song } from "../types/playlist.js";
4
+
5
+ // mock the shared blob store
6
+ vi.mock("@freqhole/api-client/storage", () => ({
7
+ storeBlob: vi.fn(),
8
+ getBlobMetadata: vi.fn(),
9
+ }));
10
+
11
+ // mock fetch globally
12
+ const mockFetch = vi.fn();
13
+ global.fetch = mockFetch;
14
+
15
+ import {
16
+ streamAudioWithCaching,
17
+ downloadAndCacheAudio,
18
+ downloadSongIfNeeded,
19
+ isSongDownloading,
20
+ } from "./streamingAudioService.js";
21
+ import { storeBlob, getBlobMetadata } from "@freqhole/api-client/storage";
22
+
23
+ function makeSong(overrides: Partial<Song> = {}): Song {
24
+ return {
25
+ id: "test-song-1",
26
+ title: "test song",
27
+ artist: "test artist",
28
+ album: "test album",
29
+ duration: 180,
30
+ position: 0,
31
+ mimeType: "audio/mpeg",
32
+ originalFilename: "test.mp3",
33
+ createdAt: Date.now(),
34
+ updatedAt: Date.now(),
35
+ playlistId: "test-playlist",
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ // build a mock fetch response streaming the given chunks
41
+ function makeStreamResponse(
42
+ chunks: Uint8Array[],
43
+ headers: Record<string, string | null> = {}
44
+ ) {
45
+ let i = 0;
46
+ return {
47
+ ok: true,
48
+ headers: {
49
+ get: vi.fn((name: string) => headers[name] ?? null),
50
+ },
51
+ body: {
52
+ getReader: vi.fn(() => ({
53
+ read: vi.fn(async () => {
54
+ if (i < chunks.length) {
55
+ return { done: false, value: chunks[i++] };
56
+ }
57
+ return { done: true, value: undefined };
58
+ }),
59
+ })),
60
+ },
61
+ };
62
+ }
63
+
64
+ beforeEach(() => {
65
+ vi.clearAllMocks();
66
+ vi.mocked(getBlobMetadata).mockResolvedValue(null);
67
+ vi.mocked(storeBlob).mockResolvedValue("mock-sha256");
68
+ });
69
+
70
+ describe("streamAudioWithCaching", () => {
71
+ it("returns the streaming url immediately and a download promise", async () => {
72
+ mockFetch.mockResolvedValue(
73
+ makeStreamResponse([new Uint8Array([1, 2, 3])], {
74
+ "content-length": "3",
75
+ "content-type": "audio/mpeg",
76
+ })
77
+ );
78
+
79
+ const result = await streamAudioWithCaching(
80
+ makeSong(),
81
+ "https://example.com/audio.mp3"
82
+ );
83
+
84
+ expect(result.blobUrl).toBe("https://example.com/audio.mp3");
85
+ await expect(result.downloadPromise).resolves.toBe(true);
86
+ expect(storeBlob).toHaveBeenCalledTimes(1);
87
+ });
88
+ });
89
+
90
+ describe("downloadAndCacheAudio", () => {
91
+ it("downloads and stores audio in the blob store", async () => {
92
+ mockFetch.mockResolvedValue(
93
+ makeStreamResponse([new Uint8Array([1, 2]), new Uint8Array([3, 4])], {
94
+ "content-length": "4",
95
+ "content-type": "audio/mpeg",
96
+ })
97
+ );
98
+
99
+ const result = await downloadAndCacheAudio(
100
+ makeSong(),
101
+ "https://example.com/audio.mp3"
102
+ );
103
+
104
+ expect(result).toBe(true);
105
+ expect(storeBlob).toHaveBeenCalledTimes(1);
106
+ expect(vi.mocked(storeBlob).mock.calls[0]![1]).toBe("audio/mpeg");
107
+ });
108
+
109
+ it("returns true without fetching when the sha is already in the blob store", async () => {
110
+ vi.mocked(getBlobMetadata).mockResolvedValue({
111
+ blob_id: "abc",
112
+ storage_type: "opfs",
113
+ storage_path: "/blobs/abc",
114
+ mime_type: "audio/mpeg",
115
+ file_size: 4,
116
+ created_at: Date.now(),
117
+ });
118
+
119
+ const result = await downloadAndCacheAudio(
120
+ makeSong({ sha: "abc" }),
121
+ "https://example.com/audio.mp3"
122
+ );
123
+
124
+ expect(result).toBe(true);
125
+ expect(mockFetch).not.toHaveBeenCalled();
126
+ expect(storeBlob).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it("reports progress when content-length is present", async () => {
130
+ mockFetch.mockResolvedValue(
131
+ makeStreamResponse([new Uint8Array([1, 2]), new Uint8Array([3, 4])], {
132
+ "content-length": "4",
133
+ })
134
+ );
135
+ const onProgress = vi.fn();
136
+
137
+ await downloadAndCacheAudio(
138
+ makeSong(),
139
+ "https://example.com/audio.mp3",
140
+ onProgress
141
+ );
142
+
143
+ expect(onProgress).toHaveBeenCalledWith({
144
+ loaded: 4,
145
+ total: 4,
146
+ percentage: 100,
147
+ });
148
+ });
149
+
150
+ it("handles a missing content-length header (no progress callbacks)", async () => {
151
+ mockFetch.mockResolvedValue(
152
+ makeStreamResponse([new Uint8Array([1, 2, 3])], {})
153
+ );
154
+ const onProgress = vi.fn();
155
+
156
+ const result = await downloadAndCacheAudio(
157
+ makeSong(),
158
+ "https://example.com/audio.mp3",
159
+ onProgress
160
+ );
161
+
162
+ expect(result).toBe(true);
163
+ expect(onProgress).not.toHaveBeenCalled();
164
+ });
165
+
166
+ it("falls back to the response content-type when the song has no mime type", async () => {
167
+ mockFetch.mockResolvedValue(
168
+ makeStreamResponse([new Uint8Array([1])], {
169
+ "content-type": "audio/ogg",
170
+ })
171
+ );
172
+
173
+ await downloadAndCacheAudio(
174
+ makeSong({ mimeType: "" }),
175
+ "https://example.com/audio.ogg"
176
+ );
177
+
178
+ expect(vi.mocked(storeBlob).mock.calls[0]![1]).toBe("audio/ogg");
179
+ });
180
+
181
+ it("returns false when the fetch fails", async () => {
182
+ mockFetch.mockResolvedValue({ ok: false, status: 404, statusText: "nope" });
183
+
184
+ const result = await downloadAndCacheAudio(
185
+ makeSong(),
186
+ "https://example.com/missing.mp3"
187
+ );
188
+
189
+ expect(result).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe("downloadSongIfNeeded", () => {
194
+ it("downloads when not cached", async () => {
195
+ mockFetch.mockResolvedValue(
196
+ makeStreamResponse([new Uint8Array([1, 2, 3])], {})
197
+ );
198
+
199
+ const result = await downloadSongIfNeeded(
200
+ makeSong(),
201
+ "https://example.com/audio.mp3"
202
+ );
203
+
204
+ expect(result).toBe(true);
205
+ expect(mockFetch).toHaveBeenCalled();
206
+ });
207
+
208
+ it("returns true without downloading when the sha is cached", async () => {
209
+ vi.mocked(getBlobMetadata).mockResolvedValue({
210
+ blob_id: "cached",
211
+ storage_type: "opfs",
212
+ storage_path: "/blobs/cached",
213
+ mime_type: "audio/mpeg",
214
+ file_size: 1,
215
+ created_at: Date.now(),
216
+ });
217
+
218
+ const result = await downloadSongIfNeeded(
219
+ makeSong({ sha256: "cached" }),
220
+ "https://example.com/audio.mp3"
221
+ );
222
+
223
+ expect(result).toBe(true);
224
+ expect(mockFetch).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it("proceeds with download when the cache check throws", async () => {
228
+ vi.mocked(getBlobMetadata).mockRejectedValue(new Error("idb broke"));
229
+ mockFetch.mockResolvedValue(
230
+ makeStreamResponse([new Uint8Array([1])], {})
231
+ );
232
+
233
+ const result = await downloadSongIfNeeded(
234
+ makeSong({ sha: "whatever" }),
235
+ "https://example.com/audio.mp3"
236
+ );
237
+
238
+ expect(result).toBe(true);
239
+ expect(mockFetch).toHaveBeenCalled();
240
+ });
241
+
242
+ it("dedupes concurrent downloads for the same song", async () => {
243
+ let resolveRead: (() => void) | undefined;
244
+ const gate = new Promise<void>((r) => (resolveRead = r));
245
+ mockFetch.mockImplementation(async () => {
246
+ await gate;
247
+ return makeStreamResponse([new Uint8Array([1])], {});
248
+ });
249
+
250
+ const song = makeSong();
251
+ const p1 = downloadSongIfNeeded(song, "https://example.com/a.mp3");
252
+ const p2 = downloadSongIfNeeded(song, "https://example.com/a.mp3");
253
+
254
+ expect(isSongDownloading(song.id)).toBe(true);
255
+ resolveRead?.();
256
+
257
+ const [r1, r2] = await Promise.all([p1, p2]);
258
+ expect(r1).toBe(true);
259
+ expect(r2).toBe(true);
260
+ expect(mockFetch).toHaveBeenCalledTimes(1);
261
+ });
262
+
263
+ it("clears the active download tracker when finished", async () => {
264
+ mockFetch.mockResolvedValue(
265
+ makeStreamResponse([new Uint8Array([1])], {})
266
+ );
267
+
268
+ const song = makeSong();
269
+ await downloadSongIfNeeded(song, "https://example.com/a.mp3");
270
+ // allow the .finally cleanup microtask to run
271
+ await Promise.resolve();
272
+
273
+ expect(isSongDownloading(song.id)).toBe(false);
274
+ });
275
+ });
@@ -0,0 +1,166 @@
1
+ // streaming audio service
2
+ // handles efficient audio streaming with parallel caching to the blob store
3
+
4
+ import { storeBlob } from "@freqhole/api-client/storage";
5
+ import type { Song } from "../types/playlist.js";
6
+
7
+ interface StreamingDownloadResult {
8
+ blobUrl: string;
9
+ downloadPromise: Promise<boolean>;
10
+ }
11
+
12
+ interface DownloadProgress {
13
+ loaded: number;
14
+ total: number;
15
+ percentage: number;
16
+ }
17
+
18
+ type ProgressCallback = (progress: DownloadProgress) => void;
19
+
20
+ // downloads audio file with streaming, providing immediate url for playback
21
+ // while simultaneously caching to the blob store
22
+ export async function streamAudioWithCaching(
23
+ song: Song,
24
+ standaloneFilePath: string,
25
+ onProgress?: ProgressCallback
26
+ ): Promise<StreamingDownloadResult> {
27
+ try {
28
+ // for http/https urls, return the direct url for immediate streaming
29
+ const blobUrl = standaloneFilePath;
30
+
31
+ // start background download and caching
32
+ const downloadPromise = downloadAndCacheAudio(
33
+ song,
34
+ standaloneFilePath,
35
+ onProgress
36
+ );
37
+
38
+ return {
39
+ blobUrl,
40
+ downloadPromise,
41
+ };
42
+ } catch (error) {
43
+ console.error("error in streamaudiowithcaching:", error);
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ // downloads and caches audio file in the blob store
49
+ export async function downloadAndCacheAudio(
50
+ song: Song,
51
+ standaloneFilePath: string,
52
+ onProgress?: ProgressCallback
53
+ ): Promise<boolean> {
54
+ try {
55
+ // if the song already has a sha, check the blob store.
56
+ // a failed check is non-fatal - proceed with the download.
57
+ if (song.sha ?? song.sha256) {
58
+ try {
59
+ const { getBlobMetadata } = await import("@freqhole/api-client/storage");
60
+ const existing = await getBlobMetadata((song.sha ?? song.sha256)!);
61
+ if (existing) {
62
+ return true; // already cached
63
+ }
64
+ } catch (error) {
65
+ console.error("error checking blob store cache status:", error);
66
+ }
67
+ }
68
+
69
+ const response = await fetch(standaloneFilePath);
70
+
71
+ if (!response.ok) {
72
+ throw new Error(
73
+ `failed to fetch: ${response.status} ${response.statusText}`
74
+ );
75
+ }
76
+
77
+ const contentLength = response.headers.get("content-length");
78
+ const total = contentLength ? parseInt(contentLength, 10) : 0;
79
+ let loaded = 0;
80
+
81
+ const reader = response.body?.getReader();
82
+ if (!reader) {
83
+ throw new Error("response body is not readable");
84
+ }
85
+
86
+ const chunks: Uint8Array[] = [];
87
+
88
+ while (true) {
89
+ const { done, value } = await reader.read();
90
+
91
+ if (done) break;
92
+
93
+ if (value) {
94
+ loaded += value.length;
95
+ chunks.push(value);
96
+
97
+ if (onProgress && total > 0) {
98
+ onProgress({
99
+ loaded,
100
+ total,
101
+ percentage: Math.round((loaded / total) * 100),
102
+ });
103
+ }
104
+ }
105
+ }
106
+
107
+ // combine chunks into a blob
108
+ const mimeType =
109
+ song.mimeType || response.headers.get("content-type") || "audio/mpeg";
110
+ const audioBlob = new Blob(chunks as BlobPart[], { type: mimeType });
111
+
112
+ // store in blob store - the sha256 hash is computed by storeBlob
113
+ await storeBlob(audioBlob, mimeType);
114
+
115
+ return true;
116
+ } catch (error) {
117
+ console.error(`error downloading and caching audio for ${song.id}:`, error);
118
+ return false;
119
+ }
120
+ }
121
+
122
+ // tracks active downloads to prevent duplicates
123
+ const activeDownloads = new Map<string, Promise<boolean>>();
124
+
125
+ export function isSongDownloading(songId: string): boolean {
126
+ return activeDownloads.has(songId);
127
+ }
128
+
129
+ // wrapper that tracks active downloads to prevent duplicates
130
+ export async function downloadSongIfNeeded(
131
+ song: Song,
132
+ standaloneFilePath: string,
133
+ onProgress?: ProgressCallback
134
+ ): Promise<boolean> {
135
+ const existingDownload = activeDownloads.get(song.id);
136
+ if (existingDownload) {
137
+ return existingDownload;
138
+ }
139
+
140
+ // check if already cached in blob store
141
+ if (song.sha ?? song.sha256) {
142
+ try {
143
+ const { getBlobMetadata } = await import("@freqhole/api-client/storage");
144
+ const existing = await getBlobMetadata((song.sha ?? song.sha256)!);
145
+ if (existing) {
146
+ return true;
147
+ }
148
+ } catch (error) {
149
+ console.error("error checking blob store cache status:", error);
150
+ }
151
+ }
152
+
153
+ const downloadPromise = downloadAndCacheAudio(
154
+ song,
155
+ standaloneFilePath,
156
+ onProgress
157
+ );
158
+
159
+ activeDownloads.set(song.id, downloadPromise);
160
+
161
+ downloadPromise.finally(() => {
162
+ activeDownloads.delete(song.id);
163
+ });
164
+
165
+ return downloadPromise;
166
+ }