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