@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,269 @@
|
|
|
1
|
+
// tests for the automerge-repo singleton module.
|
|
2
|
+
//
|
|
3
|
+
// uses real @automerge/automerge-repo + IndexedDBStorageAdapter (via
|
|
4
|
+
// fake-indexeddb from test-setup) + mocked IrohNetworkAdapter and p2pService.
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
// --- mocks (hoisted before module imports) ---
|
|
9
|
+
|
|
10
|
+
const { MockIrohNetworkAdapterClass } = vi.hoisted(() => {
|
|
11
|
+
return { MockIrohNetworkAdapterClass: vi.fn() };
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
vi.mock("@freqhole/api-client/automerge", async () => {
|
|
15
|
+
const { NetworkAdapter } = await vi.importActual<
|
|
16
|
+
typeof import("@automerge/automerge-repo")
|
|
17
|
+
>("@automerge/automerge-repo");
|
|
18
|
+
|
|
19
|
+
class MockIrohNetworkAdapter extends NetworkAdapter {
|
|
20
|
+
isReady() {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
async whenReady() {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
connect() {}
|
|
27
|
+
disconnect() {}
|
|
28
|
+
send() {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
MockIrohNetworkAdapterClass.mockImplementation(
|
|
32
|
+
(...args: unknown[]) => new MockIrohNetworkAdapter(...(args as []))
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return { IrohNetworkAdapter: MockIrohNetworkAdapterClass };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
vi.mock("./p2pService.js", () => ({
|
|
39
|
+
getAdapterOptions: vi.fn(() => ({
|
|
40
|
+
getNode: async () => {
|
|
41
|
+
throw new Error("not available in tests");
|
|
42
|
+
},
|
|
43
|
+
getIdentity: async () => null,
|
|
44
|
+
})),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
import {
|
|
48
|
+
getRepo,
|
|
49
|
+
createPlaylistDoc,
|
|
50
|
+
findPlaylistDoc,
|
|
51
|
+
deletePlaylistDoc,
|
|
52
|
+
_resetRepoForTests,
|
|
53
|
+
_testSharePolicy,
|
|
54
|
+
} from "./automergeRepo.js";
|
|
55
|
+
import { parseAutomergeUrl } from "@automerge/automerge-repo";
|
|
56
|
+
import type { PeerId, DocumentId } from "@automerge/automerge-repo";
|
|
57
|
+
import { addPeer } from "@freqhole/api-client/playlistz";
|
|
58
|
+
|
|
59
|
+
describe("automergeRepo", () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
_resetRepoForTests();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("getRepo()", () => {
|
|
65
|
+
it("returns a repo instance", () => {
|
|
66
|
+
const repo = getRepo();
|
|
67
|
+
expect(repo).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns the same singleton on repeated calls", () => {
|
|
71
|
+
const r1 = getRepo();
|
|
72
|
+
const r2 = getRepo();
|
|
73
|
+
expect(r1).toBe(r2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns a fresh repo after _resetRepoForTests", () => {
|
|
77
|
+
const r1 = getRepo();
|
|
78
|
+
_resetRepoForTests();
|
|
79
|
+
const r2 = getRepo();
|
|
80
|
+
expect(r1).not.toBe(r2);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("createPlaylistDoc()", () => {
|
|
85
|
+
it("returns an AutomergeUrl and a DocHandle", () => {
|
|
86
|
+
const { docId, handle } = createPlaylistDoc();
|
|
87
|
+
expect(docId).toMatch(/^automerge:/);
|
|
88
|
+
expect(handle).toBeDefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("seeds the doc with PlaylistDoc defaults", () => {
|
|
92
|
+
const { handle } = createPlaylistDoc();
|
|
93
|
+
const doc = handle.doc();
|
|
94
|
+
expect(doc).toBeDefined();
|
|
95
|
+
expect(doc?.version).toBe(1);
|
|
96
|
+
expect(doc?.title).toBe("");
|
|
97
|
+
expect(doc?.songs).toEqual({});
|
|
98
|
+
expect(doc?.order).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("applies initial overrides to the doc", () => {
|
|
102
|
+
const { handle } = createPlaylistDoc({
|
|
103
|
+
title: "my playlist",
|
|
104
|
+
description: "a test",
|
|
105
|
+
});
|
|
106
|
+
const doc = handle.doc();
|
|
107
|
+
expect(doc?.title).toBe("my playlist");
|
|
108
|
+
expect(doc?.description).toBe("a test");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("each call produces a unique docId", () => {
|
|
112
|
+
const { docId: id1 } = createPlaylistDoc();
|
|
113
|
+
const { docId: id2 } = createPlaylistDoc();
|
|
114
|
+
expect(id1).not.toBe(id2);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("findPlaylistDoc()", () => {
|
|
119
|
+
it("returns the same handle as was created", async () => {
|
|
120
|
+
const { docId } = createPlaylistDoc({ title: "find test" });
|
|
121
|
+
const found = await findPlaylistDoc(docId);
|
|
122
|
+
const doc = found.doc();
|
|
123
|
+
expect(doc?.title).toBe("find test");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("round-trip preserves the full doc content", async () => {
|
|
127
|
+
const { docId, handle: orig } = createPlaylistDoc({
|
|
128
|
+
title: "roundtrip",
|
|
129
|
+
description: "desc",
|
|
130
|
+
});
|
|
131
|
+
orig.change((d) => {
|
|
132
|
+
d.order.push("song-1");
|
|
133
|
+
d.songs["song-1"] = {
|
|
134
|
+
id: "song-1",
|
|
135
|
+
title: "track",
|
|
136
|
+
artist: "artist",
|
|
137
|
+
album: "album",
|
|
138
|
+
duration: 120,
|
|
139
|
+
mimeType: "audio/mp3",
|
|
140
|
+
fileSize: 100,
|
|
141
|
+
sha256: "abc123",
|
|
142
|
+
images: [],
|
|
143
|
+
urls: [],
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
const found = await findPlaylistDoc(docId);
|
|
147
|
+
const doc = found.doc();
|
|
148
|
+
expect(doc?.title).toBe("roundtrip");
|
|
149
|
+
expect(doc?.order).toContain("song-1");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("sharePolicy via _testSharePolicy()", () => {
|
|
154
|
+
it("denies peers not recorded in the doc", async () => {
|
|
155
|
+
const { docId } = createPlaylistDoc();
|
|
156
|
+
const { documentId } = parseAutomergeUrl(docId);
|
|
157
|
+
const stranger = "stranger-node" as PeerId;
|
|
158
|
+
const allowed = await _testSharePolicy(
|
|
159
|
+
stranger,
|
|
160
|
+
documentId as unknown as DocumentId
|
|
161
|
+
);
|
|
162
|
+
expect(allowed).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("denies unknown documentId (not in cache)", async () => {
|
|
166
|
+
const unknownId = "2BmFCMEUanPd5grDGtGfwd" as unknown as DocumentId;
|
|
167
|
+
const peerId = "some-peer" as PeerId;
|
|
168
|
+
expect(await _testSharePolicy(peerId, unknownId)).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("allows a peer recorded in the doc's peers map", async () => {
|
|
172
|
+
const { docId, handle } = createPlaylistDoc();
|
|
173
|
+
const { documentId } = parseAutomergeUrl(docId);
|
|
174
|
+
|
|
175
|
+
// add a peer via the shared addPeer mutator
|
|
176
|
+
handle.change((doc) => addPeer(doc, "known-peer-id"));
|
|
177
|
+
|
|
178
|
+
const peerId = "known-peer-id" as PeerId;
|
|
179
|
+
const allowed = await _testSharePolicy(
|
|
180
|
+
peerId,
|
|
181
|
+
documentId as unknown as DocumentId
|
|
182
|
+
);
|
|
183
|
+
expect(allowed).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("allows a peer recorded in the doc's acl", async () => {
|
|
187
|
+
const { docId, handle } = createPlaylistDoc();
|
|
188
|
+
const { documentId } = parseAutomergeUrl(docId);
|
|
189
|
+
|
|
190
|
+
handle.change((doc) => {
|
|
191
|
+
if (!doc.acl) doc.acl = {};
|
|
192
|
+
doc.acl["acl-peer"] = { role: "viewer" };
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const peerId = "acl-peer" as PeerId;
|
|
196
|
+
const allowed = await _testSharePolicy(
|
|
197
|
+
peerId,
|
|
198
|
+
documentId as unknown as DocumentId
|
|
199
|
+
);
|
|
200
|
+
expect(allowed).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("denies a stranger even when other peers are allowed", async () => {
|
|
204
|
+
const { docId, handle } = createPlaylistDoc();
|
|
205
|
+
const { documentId } = parseAutomergeUrl(docId);
|
|
206
|
+
|
|
207
|
+
handle.change((doc) => addPeer(doc, "known-peer-id"));
|
|
208
|
+
|
|
209
|
+
const stranger = "not-a-peer" as PeerId;
|
|
210
|
+
const allowed = await _testSharePolicy(
|
|
211
|
+
stranger,
|
|
212
|
+
documentId as unknown as DocumentId
|
|
213
|
+
);
|
|
214
|
+
expect(allowed).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("deletePlaylistDoc()", () => {
|
|
219
|
+
it("fires the delete event on the handle", async () => {
|
|
220
|
+
const { docId, handle } = createPlaylistDoc({ title: "to delete" });
|
|
221
|
+
|
|
222
|
+
let deleteReceived = false;
|
|
223
|
+
handle.on("delete", () => {
|
|
224
|
+
deleteReceived = true;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await deletePlaylistDoc(docId);
|
|
228
|
+
expect(deleteReceived).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("tombstones the doc before deleting it", async () => {
|
|
232
|
+
const { docId, handle } = createPlaylistDoc({ title: "tombstone test" });
|
|
233
|
+
|
|
234
|
+
let tombstoneDoc: { deleted?: boolean } | undefined;
|
|
235
|
+
handle.on("change", ({ doc }) => {
|
|
236
|
+
tombstoneDoc = doc as { deleted?: boolean };
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await deletePlaylistDoc(docId);
|
|
240
|
+
|
|
241
|
+
// tombstoneDoc should have been set by the change event before delete
|
|
242
|
+
expect(tombstoneDoc?.deleted).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("removes the doc from the share policy cache", async () => {
|
|
246
|
+
const { docId, handle } = createPlaylistDoc();
|
|
247
|
+
const { documentId } = parseAutomergeUrl(docId);
|
|
248
|
+
|
|
249
|
+
// put a peer in the cache
|
|
250
|
+
handle.change((doc) => addPeer(doc, "some-peer"));
|
|
251
|
+
expect(
|
|
252
|
+
await _testSharePolicy(
|
|
253
|
+
"some-peer" as PeerId,
|
|
254
|
+
documentId as unknown as DocumentId
|
|
255
|
+
)
|
|
256
|
+
).toBe(true);
|
|
257
|
+
|
|
258
|
+
await deletePlaylistDoc(docId);
|
|
259
|
+
|
|
260
|
+
// cache entry removed - should now deny
|
|
261
|
+
expect(
|
|
262
|
+
await _testSharePolicy(
|
|
263
|
+
"some-peer" as PeerId,
|
|
264
|
+
documentId as unknown as DocumentId
|
|
265
|
+
)
|
|
266
|
+
).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// automerge-repo singleton for playlistz.
|
|
2
|
+
//
|
|
3
|
+
// wires together:
|
|
4
|
+
// - IndexedDBStorageAdapter ("freqhole-automerge" db)
|
|
5
|
+
// - BroadcastChannelNetworkAdapter (cross-tab sync)
|
|
6
|
+
// - IrohNetworkAdapter (p2p via midden; defers until identity is available)
|
|
7
|
+
//
|
|
8
|
+
// all playlist docs live in this single repo instance. the repo is lazily
|
|
9
|
+
// constructed on first call to getRepo().
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
Repo,
|
|
13
|
+
parseAutomergeUrl,
|
|
14
|
+
type DocHandle,
|
|
15
|
+
type AutomergeUrl,
|
|
16
|
+
type PeerId,
|
|
17
|
+
type DocumentId,
|
|
18
|
+
} from "@automerge/automerge-repo";
|
|
19
|
+
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
|
|
20
|
+
import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel";
|
|
21
|
+
import { IrohNetworkAdapter } from "@freqhole/api-client/automerge";
|
|
22
|
+
import {
|
|
23
|
+
parsePlaylistDoc,
|
|
24
|
+
emptyPlaylistDoc,
|
|
25
|
+
tombstone,
|
|
26
|
+
type PlaylistDoc,
|
|
27
|
+
} from "@freqhole/api-client/playlistz";
|
|
28
|
+
import { getAdapterOptions } from "./p2pService.js";
|
|
29
|
+
import { log } from "../utils/log.js";
|
|
30
|
+
|
|
31
|
+
// per-doc peer registry used by sharePolicy to avoid a round-trip through
|
|
32
|
+
// repo.find(). keyed by DocumentId (the base58 part of the AutomergeUrl).
|
|
33
|
+
// updated whenever a doc is created, found, or receives a change event.
|
|
34
|
+
const docPeerCache = new Map<
|
|
35
|
+
DocumentId,
|
|
36
|
+
{ peers: Set<string>; acl: Set<string> }
|
|
37
|
+
>();
|
|
38
|
+
|
|
39
|
+
function updateCacheFromDoc(documentId: DocumentId, rawDoc: unknown): void {
|
|
40
|
+
const doc = parsePlaylistDoc(rawDoc);
|
|
41
|
+
docPeerCache.set(documentId, {
|
|
42
|
+
peers: new Set(Object.keys(doc.peers)),
|
|
43
|
+
acl: new Set(Object.keys(doc.acl ?? {})),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// share policy: only announce a doc to a peer recorded in that doc's
|
|
48
|
+
// peers map or acl. docs not in the cache (unknown to this instance) are
|
|
49
|
+
// not announced. this matches the plan's access model - the doc id is an
|
|
50
|
+
// unguessable bearer capability; unsolicited announcement is off by default.
|
|
51
|
+
let _sharePolicyCalls = 0;
|
|
52
|
+
async function sharePolicy(
|
|
53
|
+
peerId: PeerId,
|
|
54
|
+
documentId?: DocumentId
|
|
55
|
+
): Promise<boolean> {
|
|
56
|
+
_sharePolicyCalls++;
|
|
57
|
+
if (!documentId) return false;
|
|
58
|
+
const entry = docPeerCache.get(documentId);
|
|
59
|
+
if (!entry) return false;
|
|
60
|
+
return entry.peers.has(peerId) || entry.acl.has(peerId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let _repo: Repo | null = null;
|
|
64
|
+
let _irohAdapter: IrohNetworkAdapter | null = null;
|
|
65
|
+
|
|
66
|
+
function buildRepo(): Repo {
|
|
67
|
+
log.trace("automerge.repo", "buildRepo: constructing");
|
|
68
|
+
const storage = new IndexedDBStorageAdapter("freqhole-automerge");
|
|
69
|
+
const broadcastAdapter = new BroadcastChannelNetworkAdapter();
|
|
70
|
+
const irohAdapter = new IrohNetworkAdapter(getAdapterOptions());
|
|
71
|
+
_irohAdapter = irohAdapter;
|
|
72
|
+
|
|
73
|
+
const repo = new Repo({
|
|
74
|
+
storage,
|
|
75
|
+
network: [broadcastAdapter, irohAdapter],
|
|
76
|
+
sharePolicy,
|
|
77
|
+
});
|
|
78
|
+
return repo;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// returns the lazily-constructed repo singleton.
|
|
82
|
+
// subsequent calls return the same instance.
|
|
83
|
+
export function getRepo(): Repo {
|
|
84
|
+
if (!_repo) {
|
|
85
|
+
_repo = buildRepo();
|
|
86
|
+
}
|
|
87
|
+
return _repo;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// returns the iroh network adapter wired into the repo. constructing the
|
|
91
|
+
// repo if needed. used by sharing/blob services for addPeer, alpn handlers
|
|
92
|
+
// and connection state.
|
|
93
|
+
export function getIrohAdapter(): IrohNetworkAdapter {
|
|
94
|
+
if (!_irohAdapter) {
|
|
95
|
+
getRepo();
|
|
96
|
+
}
|
|
97
|
+
return _irohAdapter!;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// attach a change listener that keeps the peer cache current for a handle.
|
|
101
|
+
// also seeds the cache from whatever the handle has now (if ready).
|
|
102
|
+
// documentIds that already have a change listener attached via watchHandle.
|
|
103
|
+
// prevents unbounded listener growth when findPlaylistDoc is called repeatedly.
|
|
104
|
+
const watchedDocs = new Set<DocumentId>();
|
|
105
|
+
|
|
106
|
+
let _watchHandleCalls = 0;
|
|
107
|
+
function watchHandle(
|
|
108
|
+
handle: DocHandle<PlaylistDoc>,
|
|
109
|
+
documentId: DocumentId
|
|
110
|
+
): void {
|
|
111
|
+
_watchHandleCalls++;
|
|
112
|
+
let rawDoc: unknown;
|
|
113
|
+
try {
|
|
114
|
+
rawDoc = handle.doc();
|
|
115
|
+
} catch {
|
|
116
|
+
rawDoc = undefined;
|
|
117
|
+
}
|
|
118
|
+
if (rawDoc !== undefined) {
|
|
119
|
+
updateCacheFromDoc(documentId, rawDoc);
|
|
120
|
+
}
|
|
121
|
+
if (!watchedDocs.has(documentId)) {
|
|
122
|
+
watchedDocs.add(documentId);
|
|
123
|
+
handle.on("change", ({ doc }) => {
|
|
124
|
+
log.trace("automerge.repo", "doc change event", documentId);
|
|
125
|
+
updateCacheFromDoc(documentId, doc);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// create a new playlist doc seeded with emptyPlaylistDoc + optional overrides.
|
|
131
|
+
// returns the AutomergeUrl (docId) and the DocHandle synchronously.
|
|
132
|
+
export function createPlaylistDoc(initial?: Partial<PlaylistDoc>): {
|
|
133
|
+
docId: AutomergeUrl;
|
|
134
|
+
handle: DocHandle<PlaylistDoc>;
|
|
135
|
+
} {
|
|
136
|
+
const repo = getRepo();
|
|
137
|
+
const seed = emptyPlaylistDoc(initial);
|
|
138
|
+
const handle = repo.create<PlaylistDoc>(seed);
|
|
139
|
+
log.trace("automerge.repo", "createPlaylistDoc:", handle.url);
|
|
140
|
+
const { documentId } = parseAutomergeUrl(handle.url);
|
|
141
|
+
watchHandle(handle, documentId);
|
|
142
|
+
return { docId: handle.url, handle };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// pre-authorize a peer for a doc we don't have yet. seeds the peer cache
|
|
146
|
+
// so sharePolicy lets us request the doc from (and sync it with) that peer
|
|
147
|
+
// before the doc has arrived locally. without this, opening a share link
|
|
148
|
+
// dead-ends: the policy only trusts peers recorded in the doc, but the doc
|
|
149
|
+
// can't arrive until the policy trusts the peer.
|
|
150
|
+
export function authorizePeerForDoc(docId: AutomergeUrl, nodeId: string): void {
|
|
151
|
+
const { documentId } = parseAutomergeUrl(docId);
|
|
152
|
+
const entry = docPeerCache.get(documentId) ?? {
|
|
153
|
+
peers: new Set<string>(),
|
|
154
|
+
acl: new Set<string>(),
|
|
155
|
+
};
|
|
156
|
+
entry.peers.add(nodeId);
|
|
157
|
+
docPeerCache.set(documentId, entry);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// per-docId promise cache so repeated calls for the same doc share one
|
|
161
|
+
// repo.find() call and one watchHandle setup. the cache holds a promise
|
|
162
|
+
// (not a resolved handle) so concurrent first-callers coalesce correctly.
|
|
163
|
+
const _handleCache = new Map<AutomergeUrl, Promise<DocHandle<PlaylistDoc>>>();
|
|
164
|
+
|
|
165
|
+
// find an existing playlist doc by its AutomergeUrl, waiting for the handle
|
|
166
|
+
// to reach a ready (or terminal) state before returning.
|
|
167
|
+
// cached: subsequent calls for the same docId return the same promise.
|
|
168
|
+
let _findCalls = 0;
|
|
169
|
+
export async function findPlaylistDoc(
|
|
170
|
+
docId: AutomergeUrl
|
|
171
|
+
): Promise<DocHandle<PlaylistDoc>> {
|
|
172
|
+
const cached = _handleCache.get(docId);
|
|
173
|
+
if (cached) return cached;
|
|
174
|
+
|
|
175
|
+
_findCalls++;
|
|
176
|
+
log.trace("automerge.repo", "findPlaylistDoc call #", String(_findCalls), docId);
|
|
177
|
+
|
|
178
|
+
const promise = (async () => {
|
|
179
|
+
const repo = getRepo();
|
|
180
|
+
const handle = await repo.find<PlaylistDoc>(docId);
|
|
181
|
+
log.trace("automerge.repo", "findPlaylistDoc resolved", docId);
|
|
182
|
+
const { documentId } = parseAutomergeUrl(handle.url);
|
|
183
|
+
watchHandle(handle, documentId);
|
|
184
|
+
return handle;
|
|
185
|
+
})();
|
|
186
|
+
|
|
187
|
+
_handleCache.set(docId, promise);
|
|
188
|
+
return promise;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// tombstone the doc (sets deleted: true) then remove it from local storage.
|
|
192
|
+
export async function deletePlaylistDoc(docId: AutomergeUrl): Promise<void> {
|
|
193
|
+
_handleCache.delete(docId);
|
|
194
|
+
const repo = getRepo();
|
|
195
|
+
const handle = await repo.find<PlaylistDoc>(docId);
|
|
196
|
+
handle.change((doc) => tombstone(doc));
|
|
197
|
+
const { documentId } = parseAutomergeUrl(docId);
|
|
198
|
+
docPeerCache.delete(documentId);
|
|
199
|
+
repo.delete(docId);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// flush a doc's pending changes to indexeddb. the repo debounces storage
|
|
203
|
+
// writes, so without this a reload (or tab close) shortly after a mutation
|
|
204
|
+
// can lose data.
|
|
205
|
+
export async function flushDoc(docId: AutomergeUrl): Promise<void> {
|
|
206
|
+
const repo = getRepo();
|
|
207
|
+
const { documentId } = parseAutomergeUrl(docId);
|
|
208
|
+
await repo.flush([documentId]);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// reset all singleton state. for use in tests only.
|
|
212
|
+
export function _resetRepoForTests(): void {
|
|
213
|
+
_repo = null;
|
|
214
|
+
_irohAdapter = null;
|
|
215
|
+
docPeerCache.clear();
|
|
216
|
+
watchedDocs.clear();
|
|
217
|
+
_handleCache.clear();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// expose the share policy for unit testing.
|
|
221
|
+
export async function _testSharePolicy(
|
|
222
|
+
peerId: PeerId,
|
|
223
|
+
documentId: DocumentId
|
|
224
|
+
): Promise<boolean> {
|
|
225
|
+
return sharePolicy(peerId, documentId);
|
|
226
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// dev-only mock implementations for blob transfer.
|
|
2
|
+
//
|
|
3
|
+
// this file is only loaded in DEV builds (imported by src/dev-hooks.ts which
|
|
4
|
+
// is dynamically imported under import.meta.env.DEV). never bundled for prod.
|
|
5
|
+
//
|
|
6
|
+
// exports a single factory function that creates the mock fetch override.
|
|
7
|
+
// the factory is called by dev-hooks.ts which also manages the active
|
|
8
|
+
// behaviour state and the window hook registration.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
_devSetFetchOverride,
|
|
12
|
+
_devSetBlobFetchTimeout,
|
|
13
|
+
_devEvictBlob,
|
|
14
|
+
_devFetchBlobBySha,
|
|
15
|
+
type BlobFetchProgress,
|
|
16
|
+
} from "./blobTransferService.js";
|
|
17
|
+
import { storeBlob } from "@freqhole/api-client/storage";
|
|
18
|
+
|
|
19
|
+
// the behaviour union mirrors global.d.ts Window["__mockBlobFetch"] parameter.
|
|
20
|
+
// keeping it here means the mock impl and its type live together.
|
|
21
|
+
export type MockBlobBehaviour = NonNullable<
|
|
22
|
+
Window["__mockBlobFetch"]
|
|
23
|
+
> extends (b: infer B) => void
|
|
24
|
+
? B
|
|
25
|
+
: never;
|
|
26
|
+
|
|
27
|
+
// --- synthetic blob data ---
|
|
28
|
+
|
|
29
|
+
// build a minimal valid 1s mono 16-bit PCM WAV (silence).
|
|
30
|
+
// used as a stand-in blob so the audio element gets something it can decode.
|
|
31
|
+
function makeSyntheticWav(): Uint8Array {
|
|
32
|
+
const samples = 8000;
|
|
33
|
+
const dataSize = samples * 2;
|
|
34
|
+
const buf = new ArrayBuffer(44 + dataSize);
|
|
35
|
+
const v = new DataView(buf);
|
|
36
|
+
const s = (o: number, t: string) => {
|
|
37
|
+
for (let i = 0; i < t.length; i++) v.setUint8(o + i, t.charCodeAt(i));
|
|
38
|
+
};
|
|
39
|
+
s(0, "RIFF"); v.setUint32(4, 36 + dataSize, true);
|
|
40
|
+
s(8, "WAVE"); s(12, "fmt ");
|
|
41
|
+
v.setUint32(16, 16, true); v.setUint16(20, 1, true); // PCM mono
|
|
42
|
+
v.setUint16(22, 1, true); v.setUint32(24, 8000, true); // 8kHz
|
|
43
|
+
v.setUint32(28, 16000, true); v.setUint16(32, 2, true);
|
|
44
|
+
v.setUint16(34, 16, true); s(36, "data");
|
|
45
|
+
v.setUint32(40, dataSize, true);
|
|
46
|
+
return new Uint8Array(buf);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- mock fetch implementation ---
|
|
50
|
+
|
|
51
|
+
async function mockFetchBlob(
|
|
52
|
+
sha256: string,
|
|
53
|
+
mimeType: string,
|
|
54
|
+
onProgress: ((p: BlobFetchProgress) => void) | undefined,
|
|
55
|
+
behaviour: MockBlobBehaviour
|
|
56
|
+
): Promise<string | null> {
|
|
57
|
+
if (behaviour.type === "error") {
|
|
58
|
+
throw new Error(`mock blob error: ${behaviour.code}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (behaviour.type === "stall") {
|
|
62
|
+
// hangs until the test clears the mock or the fetch timeout fires
|
|
63
|
+
return new Promise<string | null>(() => {});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const bytes = makeSyntheticWav();
|
|
67
|
+
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: mimeType });
|
|
68
|
+
const total = blob.size;
|
|
69
|
+
|
|
70
|
+
if (behaviour.type === "instant") {
|
|
71
|
+
await storeBlob(blob, mimeType);
|
|
72
|
+
return sha256;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (behaviour.type === "delayed") {
|
|
76
|
+
await new Promise<void>((res) => setTimeout(res, behaviour.ms));
|
|
77
|
+
await storeBlob(blob, mimeType);
|
|
78
|
+
return sha256;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (behaviour.type === "progress") {
|
|
82
|
+
const chunkSize = Math.ceil(total / behaviour.chunks);
|
|
83
|
+
let offset = 0;
|
|
84
|
+
for (let i = 0; i < behaviour.chunks; i++) {
|
|
85
|
+
await new Promise<void>((res) => setTimeout(res, behaviour.msPerChunk));
|
|
86
|
+
offset = Math.min(offset + chunkSize, total);
|
|
87
|
+
onProgress?.({ sha256, fraction: offset / total });
|
|
88
|
+
}
|
|
89
|
+
await storeBlob(blob, mimeType);
|
|
90
|
+
return sha256;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- window hook registration ---
|
|
97
|
+
|
|
98
|
+
// call this once at app startup (from dev-hooks.ts) to register all blob
|
|
99
|
+
// transport mock hooks on the window object.
|
|
100
|
+
export function registerBlobDevHooks(): void {
|
|
101
|
+
let activeBehaviour: MockBlobBehaviour | null = null;
|
|
102
|
+
|
|
103
|
+
window.__mockBlobFetch = (behaviour) => {
|
|
104
|
+
activeBehaviour = behaviour;
|
|
105
|
+
_devSetFetchOverride((sha256, mimeType, onProgress) => {
|
|
106
|
+
if (!activeBehaviour) return Promise.resolve(null);
|
|
107
|
+
return mockFetchBlob(sha256, mimeType, onProgress, activeBehaviour);
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
window.__clearMockBlobFetch = () => {
|
|
112
|
+
activeBehaviour = null;
|
|
113
|
+
_devSetFetchOverride(null);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
window.__evictBlob = _devEvictBlob;
|
|
117
|
+
window.__setBlobFetchTimeout = _devSetBlobFetchTimeout;
|
|
118
|
+
window.__fetchBlobBySha = _devFetchBlobBySha;
|
|
119
|
+
}
|