@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.
- package/.changeset/config.json +11 -0
- package/.changeset/nice-wolves-thank.md +5 -0
- package/.freqhole-versions.json +4 -0
- package/.github/copilot-instructions.md +201 -0
- package/.github/workflows/changesets.yml +50 -0
- package/.github/workflows/npm-publish.yml +124 -0
- package/.github/workflows/pr-checks.yml +103 -0
- package/README.md +30 -0
- package/build-component.js +141 -0
- package/build-zip-bundle-lib.js +44 -0
- package/config/playwright.config.ts +47 -0
- package/config/vite.config.ts +44 -0
- package/config/vitest.config.ts +39 -0
- package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
- package/dist/assets/index-CbOXzGiA.js +216 -0
- package/dist/assets/index-CbOXzGiA.js.map +1 -0
- package/dist/assets/index-TvJ6RFpy.css +1 -0
- package/dist/assets/midden-DceCrT_L.js +2 -0
- package/dist/assets/midden-DceCrT_L.js.map +1 -0
- package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
- package/dist/index.html +55 -0
- package/dist/sw.js +134 -0
- package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
- package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
- package/docs/E2E_TESTID_PLAN.md +234 -0
- package/docs/IROH_P2P_PLAN.md +302 -0
- package/docs/ROADMAP.md +695 -0
- package/docs/TODO.md +167 -0
- package/docs/bundle-embedding-plan.md +134 -0
- package/docs/standalone-refactor.md +184 -0
- package/e2e/all-playlists.spec.ts +220 -0
- package/e2e/audio-player.spec.ts +226 -0
- package/e2e/collaborative-features.spec.ts +229 -0
- package/e2e/contexts.ts +238 -0
- package/e2e/edit-panel.spec.ts +87 -0
- package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
- package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
- package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
- package/e2e/fixtures/chord-stack-3s.wav +0 -0
- package/e2e/fixtures/cover-anim.gif +0 -0
- package/e2e/fixtures/cover-blue.png +0 -0
- package/e2e/fixtures/cover-checkers.png +0 -0
- package/e2e/fixtures/cover-gradient.jpg +0 -0
- package/e2e/fixtures/cover-mono.gif +0 -0
- package/e2e/fixtures/cover-noise.png +0 -0
- package/e2e/fixtures/cover-plasma.webp +0 -0
- package/e2e/fixtures/cover-portrait.jpg +0 -0
- package/e2e/fixtures/cover-red.png +0 -0
- package/e2e/fixtures/cover-thumb.jpg +0 -0
- package/e2e/fixtures/cover-wide.webp +0 -0
- package/e2e/fixtures/generate.mjs +257 -0
- package/e2e/fixtures/long-drone-90s.mp3 +0 -0
- package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
- package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
- package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
- package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
- package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
- package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
- package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
- package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
- package/e2e/fixtures/tone-220hz-10s.wav +0 -0
- package/e2e/fixtures/tone-440hz-2s.wav +0 -0
- package/e2e/fixtures/tone-880hz-5s.wav +0 -0
- package/e2e/fixtures/tone-stereo-3s.wav +0 -0
- package/e2e/fixtures/user-provided/README.md +1 -0
- package/e2e/helpers/app.ts +143 -0
- package/e2e/helpers/hooks.ts +133 -0
- package/e2e/helpers/index.ts +12 -0
- package/e2e/helpers/media.ts +125 -0
- package/e2e/helpers.ts +10 -0
- package/e2e/p2p-collaboration.spec.ts +356 -0
- package/e2e/p2p-multi-peer.spec.ts +723 -0
- package/e2e/p2p-states.spec.ts +302 -0
- package/e2e/playback.spec.ts +56 -0
- package/e2e/playlist-crud.spec.ts +126 -0
- package/e2e/share-link-autoplay.spec.ts +129 -0
- package/e2e/sharing-access.spec.ts +205 -0
- package/e2e/sharing.spec.ts +195 -0
- package/e2e/song-cache-state.spec.ts +202 -0
- package/e2e/zip-bundle.spec.ts +855 -0
- package/eslint.config.js +114 -0
- package/index.html +54 -0
- package/package.json +119 -0
- package/public/sw.js +134 -0
- package/scripts/use-local.mjs +37 -0
- package/scripts/use-published.mjs +37 -0
- package/src/App.tsx +9 -0
- package/src/cli/check.ts +164 -0
- package/src/cli/generate.ts +184 -0
- package/src/cli/http.ts +88 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/init.ts +18 -0
- package/src/components/AllPlaylistsPanel.tsx +713 -0
- package/src/components/AudioPlayer.tsx +122 -0
- package/src/components/MarqueeText.tsx +101 -0
- package/src/components/PlaylistCoverModal.tsx +519 -0
- package/src/components/PlaylistEditPanel.tsx +803 -0
- package/src/components/PlaylistSharePanel.tsx +1020 -0
- package/src/components/ShareLinkKnockPanel.tsx +144 -0
- package/src/components/SharePanel.tsx +584 -0
- package/src/components/SongEditModal.tsx +453 -0
- package/src/components/SongEditPanel.tsx +578 -0
- package/src/components/SongRow.tsx +689 -0
- package/src/components/index.tsx +494 -0
- package/src/components/playlist/index.tsx +1203 -0
- package/src/context/PlaylistzContext.tsx +74 -0
- package/src/dev-hooks.ts +35 -0
- package/src/hooks/createDocIndexQuery.ts +53 -0
- package/src/hooks/createDocStore.test.ts +303 -0
- package/src/hooks/createDocStore.ts +90 -0
- package/src/hooks/useDragAndDrop.test.ts +474 -0
- package/src/hooks/useDragAndDrop.ts +400 -0
- package/src/hooks/useImageModal.test.ts +174 -0
- package/src/hooks/useImageModal.ts +201 -0
- package/src/hooks/usePlaylistManager.test.ts +453 -0
- package/src/hooks/usePlaylistManager.ts +685 -0
- package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
- package/src/hooks/usePlaylistsQuery.ts +44 -0
- package/src/hooks/useSongState.test.ts +236 -0
- package/src/hooks/useSongState.ts +114 -0
- package/src/hooks/useUIState.ts +71 -0
- package/src/index.tsx +18 -0
- package/src/services/audioService.dev.ts +22 -0
- package/src/services/audioService.test.ts +1226 -0
- package/src/services/audioService.ts +1395 -0
- package/src/services/automergeRepo.test.ts +269 -0
- package/src/services/automergeRepo.ts +226 -0
- package/src/services/blobTransferService.dev.ts +119 -0
- package/src/services/blobTransferService.test.ts +441 -0
- package/src/services/blobTransferService.ts +702 -0
- package/src/services/docIndexService.test.ts +179 -0
- package/src/services/docIndexService.ts +118 -0
- package/src/services/fileProcessingService.test.ts +554 -0
- package/src/services/fileProcessingService.ts +239 -0
- package/src/services/imageService.test.ts +701 -0
- package/src/services/imageService.ts +365 -0
- package/src/services/indexedDBService.integration.test.ts +104 -0
- package/src/services/indexedDBService.test.ts +202 -0
- package/src/services/indexedDBService.ts +436 -0
- package/src/services/offlineService.test.ts +661 -0
- package/src/services/offlineService.ts +382 -0
- package/src/services/p2pService.test.ts +305 -0
- package/src/services/p2pService.ts +344 -0
- package/src/services/playlistDocService.test.ts +448 -0
- package/src/services/playlistDocService.ts +707 -0
- package/src/services/playlistDownloadService.test.ts +674 -0
- package/src/services/playlistDownloadService.ts +389 -0
- package/src/services/sharingService.test.ts +812 -0
- package/src/services/sharingService.ts +1073 -0
- package/src/services/sharingState.ts +161 -0
- package/src/services/songReactivity.test.ts +620 -0
- package/src/services/songReactivity.ts +145 -0
- package/src/services/standaloneService.test.ts +1025 -0
- package/src/services/standaloneService.ts +588 -0
- package/src/services/streamingAudioService.test.ts +275 -0
- package/src/services/streamingAudioService.ts +166 -0
- package/src/styles.css +428 -0
- package/src/test-setup.ts +547 -0
- package/src/types/global.d.ts +40 -0
- package/src/types/playlist.ts +99 -0
- package/src/utils/hashUtils.ts +41 -0
- package/src/utils/log.ts +97 -0
- package/src/utils/m3u.test.ts +172 -0
- package/src/utils/m3u.ts +136 -0
- package/src/utils/mockData.ts +166 -0
- package/src/utils/standaloneTemplates.test.ts +175 -0
- package/src/utils/standaloneTemplates.ts +83 -0
- package/src/utils/swTemplate.ts +84 -0
- package/src/utils/timeUtils.ts +166 -0
- package/src/utils/typeGuards.ts +171 -0
- package/src/web-component.tsx +98 -0
- package/src/zip-bundle/index.ts +7 -0
- package/src/zip-bundle/m3u.ts +45 -0
- package/src/zip-bundle/types.ts +50 -0
- package/src/zip-bundle/utils.ts +33 -0
- package/src/zip-bundle/zipBuilder.ts +309 -0
- package/tailwind.config.js +55 -0
- 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
|
+
}
|