@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,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
|
+
});
|