@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,707 @@
1
+ // doc-backed playlist and song crud.
2
+ // all playlist/song mutations go through automerge handles.
3
+ // audio and image bytes are stored in the shared opfs blob store keyed by sha256.
4
+
5
+ import type { AutomergeUrl } from "@automerge/automerge-repo";
6
+ import {
7
+ createPlaylistDoc,
8
+ findPlaylistDoc,
9
+ deletePlaylistDoc,
10
+ flushDoc,
11
+ } from "./automergeRepo.js";
12
+ import {
13
+ parsePlaylistDoc,
14
+ emptyPlaylistDoc,
15
+ upsertSong,
16
+ removeSong,
17
+ reorderSongs as reorderSongsMutation,
18
+ setMetadata,
19
+ addImage,
20
+ type PlaylistDoc,
21
+ type SongEntry,
22
+ type ImageRef,
23
+ } from "@freqhole/api-client/playlistz";
24
+ import {
25
+ storeBlob,
26
+ getBlobObjectURL,
27
+ getBlobMetadata,
28
+ deleteBlob,
29
+ } from "@freqhole/api-client/storage";
30
+ import {
31
+ addDocIndexEntry,
32
+ removeDocIndexEntry,
33
+ getAllDocIndexEntries,
34
+ getDocIndexEntry,
35
+ } from "./docIndexService.js";
36
+ import { calculateSHA256 } from "../utils/hashUtils.js";
37
+ import { triggerSpecificSongUpdate } from "./songReactivity.js";
38
+ import { fetchBlobForDoc } from "./blobTransferService.js";
39
+ import { log } from "../utils/log.js";
40
+ import type { Playlist, Song } from "../types/playlist.js";
41
+ import type { DocIndexEntry } from "./indexedDBService.js";
42
+
43
+ // in-memory registry: songId -> { docId, entry, index } for getSongById lookups.
44
+ // populated whenever a playlist's songs are fetched or mutated.
45
+ const songRegistry = new Map<
46
+ string,
47
+ { docId: string; entry: SongEntry; index: number }
48
+ >();
49
+
50
+ // register all songs from a parsed doc into the registry
51
+ function registerDocSongs(docId: string, doc: PlaylistDoc): void {
52
+ for (let i = 0; i < doc.order.length; i++) {
53
+ const songId = doc.order[i]!;
54
+ const entry = doc.songs[songId];
55
+ if (entry) {
56
+ songRegistry.set(songId, { docId, entry, index: i });
57
+ }
58
+ }
59
+ // remove songs no longer in this doc from the registry
60
+ for (const [id, reg] of songRegistry.entries()) {
61
+ if (reg.docId === docId && !doc.songs[id]) {
62
+ songRegistry.delete(id);
63
+ }
64
+ }
65
+ }
66
+
67
+ // clear the registry. for use in tests only (simulates a fresh page load).
68
+ export function _clearSongRegistryForTests(): void {
69
+ songRegistry.clear();
70
+ }
71
+
72
+ // derive a file extension from a mime type
73
+ function extFromMime(mimeType: string): string {
74
+ const map: Record<string, string> = {
75
+ "audio/mpeg": "mp3",
76
+ "audio/mp3": "mp3",
77
+ "audio/wav": "wav",
78
+ "audio/wave": "wav",
79
+ "audio/x-wav": "wav",
80
+ "audio/ogg": "ogg",
81
+ "audio/flac": "flac",
82
+ "audio/x-flac": "flac",
83
+ "audio/aac": "aac",
84
+ "audio/mp4": "m4a",
85
+ "audio/x-m4a": "m4a",
86
+ };
87
+ return map[mimeType] ?? "bin";
88
+ }
89
+
90
+ // strip solid store proxies / automerge proxies down to plain JSON values.
91
+ // anything crossing into handle.change() must be a plain object - automerge
92
+ // throws "Cannot create a reference to an existing document object" if a
93
+ // doc-derived proxy is re-inserted, and solid proxies confuse serialization.
94
+ function toPlain<T>(value: T): T {
95
+ if (value === undefined || value === null) return value;
96
+ return JSON.parse(JSON.stringify(value)) as T;
97
+ }
98
+
99
+ // --- view shape adapters ---
100
+
101
+ // map a PlaylistDoc + docId to the legacy Playlist view shape components consume.
102
+ export function docToPlaylist(docId: string, doc: PlaylistDoc): Playlist {
103
+ return {
104
+ id: docId,
105
+ title: doc.title || "untitled playlist",
106
+ description: doc.description || undefined,
107
+ createdAt: doc.createdAt ? new Date(doc.createdAt).getTime() : Date.now(),
108
+ updatedAt: doc.lastModified
109
+ ? new Date(doc.lastModified).getTime()
110
+ : Date.now(),
111
+ songIds: [...doc.order],
112
+ // image fields are not eagerly loaded - blob store access is on-demand
113
+ imageData: undefined,
114
+ thumbnailData: undefined,
115
+ imageType: undefined,
116
+ // primary image sha for display (callers can load via getSongImageObjectURL)
117
+ _primaryImageSha: (doc.images.find((i) => i.isPrimary) ?? doc.images[0])
118
+ ?.blobId,
119
+ bgFilterEnabled: doc.bgFilterEnabled,
120
+ bgFilterBlur: doc.bgFilterBlur,
121
+ bgFilterContrast: doc.bgFilterContrast,
122
+ bgFilterBrightness: doc.bgFilterBrightness,
123
+ coverFilterEnabled: doc.coverFilterEnabled,
124
+ coverFilterBlur: doc.coverFilterBlur,
125
+ bgSize: doc.bgSize,
126
+ bgPosition: doc.bgPosition,
127
+ bgRepeat: doc.bgRepeat,
128
+ } as Playlist;
129
+ }
130
+
131
+ // map a SongEntry to the legacy Song view shape components consume.
132
+ // index is the position of the song in doc.order.
133
+ export function songEntryToSong(
134
+ entry: SongEntry,
135
+ docId: string,
136
+ index: number
137
+ ): Song {
138
+ return {
139
+ id: entry.id,
140
+ title: entry.title,
141
+ artist: entry.artist,
142
+ album: entry.album,
143
+ duration: entry.duration,
144
+ mimeType: entry.mimeType,
145
+ fileSize: entry.fileSize,
146
+ originalFilename: `${entry.title}.${extFromMime(entry.mimeType)}`,
147
+ position: index,
148
+ playlistId: docId,
149
+ sha: entry.sha256,
150
+ sha256: entry.sha256,
151
+ // timestamp fields not in SongEntry; use current time as a reasonable
152
+ // "when was this added to my library" fallback. the real value is unknown
153
+ // for received songs because the doc schema has no per-song timestamps.
154
+ createdAt: Date.now(),
155
+ updatedAt: Date.now(),
156
+ // image fields hydrated async via hydrateSongImage (blob store)
157
+ imageData: undefined,
158
+ thumbnailData: undefined,
159
+ imageType: undefined,
160
+ // carry image refs so callers can load from blob store
161
+ images: entry.images,
162
+ };
163
+ }
164
+
165
+ // hydrate a song view with its primary image from the blob store:
166
+ // sets imageFilePath (object url) and imageType so display components
167
+ // (getImageUrlForContext) can render it.
168
+ async function hydrateSongImage(song: Song): Promise<Song> {
169
+ const primary =
170
+ song.images?.find((i) => i.isPrimary) ?? song.images?.[0];
171
+ if (!primary) return song;
172
+ try {
173
+ const url = await getBlobObjectURL(primary.blobId);
174
+ if (!url) {
175
+ // blob not in local store - trigger a background fetch from the
176
+ // playlist's p2p peers and re-notify when it arrives so the row
177
+ // can re-render with the image.
178
+ if (song.playlistId) {
179
+ void fetchBlobForDoc(song.playlistId, primary.blobId, primary.blobType ?? "image/jpeg")
180
+ .then((result) => { if (result) triggerSpecificSongUpdate(song.id); })
181
+ .catch(() => {});
182
+ }
183
+ return song;
184
+ }
185
+ const meta = await getBlobMetadata(primary.blobId);
186
+ song.imageFilePath = url;
187
+ song.imageType = meta?.mime_type ?? "image/jpeg";
188
+ } catch {
189
+ // blob missing - leave image fields unset
190
+ }
191
+ return song;
192
+ }
193
+
194
+ // async variant of docToPlaylist that hydrates the playlist cover image
195
+ // from the blob store (imageFilePath + imageType).
196
+ export async function docToPlaylistAsync(
197
+ docId: string,
198
+ doc: PlaylistDoc
199
+ ): Promise<Playlist> {
200
+ const playlist = docToPlaylist(docId, doc);
201
+ if (playlist._primaryImageSha) {
202
+ try {
203
+ const url = await getBlobObjectURL(playlist._primaryImageSha);
204
+ if (url) {
205
+ playlist.imageFilePath = url;
206
+ const meta = await getBlobMetadata(playlist._primaryImageSha);
207
+ playlist.imageType = meta?.mime_type ?? "image/jpeg";
208
+ } else {
209
+ // blob not local - trigger background fetch from peers; the caller
210
+ // can re-render when the playlist update arrives via doc subscription.
211
+ void fetchBlobForDoc(docId, playlist._primaryImageSha, "image/jpeg")
212
+ .catch(() => {});
213
+ }
214
+ } catch {
215
+ // blob missing - leave image fields unset
216
+ }
217
+ }
218
+ return playlist;
219
+ }
220
+
221
+ // --- read helpers ---
222
+
223
+ // get all songs for a playlist doc as Song view objects.
224
+ // also populates the songRegistry for subsequent getSongById calls.
225
+ export async function getSongsForPlaylist(docId: string): Promise<Song[]> {
226
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
227
+ return getSongsFromHandle(docId, handle);
228
+ }
229
+
230
+ // same as getSongsForPlaylist but accepts an already-resolved handle.
231
+ // use this in contexts where findPlaylistDoc has already been called (e.g.
232
+ // inside a doc change handler) to avoid a redundant repo.find() call.
233
+ export async function getSongsFromHandle(
234
+ docId: string,
235
+ handle: Awaited<ReturnType<typeof findPlaylistDoc>>
236
+ ): Promise<Song[]> {
237
+ const raw = handle.doc();
238
+ if (!raw) { log.warn("playlist.doc", "getSongsFromHandle: handle.doc() returned null"); return []; }
239
+ const doc = parsePlaylistDoc(raw);
240
+ log.trace("playlist.doc", "getSongsFromHandle order=", String(doc.order.length), "songs=", String(Object.keys(doc.songs).length));
241
+ registerDocSongs(docId, doc);
242
+ const songs = doc.order
243
+ .map((id, i) => {
244
+ const entry = doc.songs[id];
245
+ if (!entry) return null;
246
+ return songEntryToSong(entry, docId, i);
247
+ })
248
+ .filter((s): s is Song => s !== null);
249
+ return Promise.all(songs.map(hydrateSongImage));
250
+ }
251
+
252
+ // coalesces concurrent registry-rebuild requests into a single operation.
253
+ // without this, N SongRow components all firing getSongById on an empty
254
+ // registry each triggers their own findPlaylistDoc call in parallel.
255
+ let _registryRebuildPromise: Promise<void> | null = null;
256
+
257
+ // get a single song by id using the in-memory registry.
258
+ // on a registry miss (e.g. right after a page reload, before any playlist's
259
+ // songs have been fetched), rebuilds the registry from the docIndex.
260
+ export async function getSongById(songId: string): Promise<Song | null> {
261
+ let reg = songRegistry.get(songId);
262
+
263
+ if (!reg) {
264
+ // coalesce all concurrent misses into a single rebuild so N SongRows
265
+ // waiting on empty registry only call findPlaylistDoc once.
266
+ if (!_registryRebuildPromise) {
267
+ _registryRebuildPromise = (async () => {
268
+ const entries = await getAllDocIndexEntries();
269
+ for (const entry of entries) {
270
+ try {
271
+ const handle = await findPlaylistDoc(entry.docId as AutomergeUrl);
272
+ const raw = handle.doc();
273
+ if (!raw) continue;
274
+ registerDocSongs(entry.docId, parsePlaylistDoc(raw));
275
+ } catch {
276
+ continue;
277
+ }
278
+ }
279
+ })().finally(() => {
280
+ _registryRebuildPromise = null;
281
+ });
282
+ }
283
+ await _registryRebuildPromise;
284
+ reg = songRegistry.get(songId);
285
+ }
286
+
287
+ if (!reg) return null;
288
+ return hydrateSongImage(songEntryToSong(reg.entry, reg.docId, reg.index));
289
+ }
290
+
291
+ // get an object url for a song's primary audio blob (sha256 key).
292
+ export async function getSongAudioObjectURL(
293
+ sha256: string
294
+ ): Promise<string | null> {
295
+ if (!sha256) return null;
296
+ return getBlobObjectURL(sha256);
297
+ }
298
+
299
+ // get an object url for a song's primary image blob.
300
+ export async function getSongImageObjectURL(
301
+ entry: SongEntry | Song
302
+ ): Promise<string | null> {
303
+ const images =
304
+ "images" in entry && Array.isArray(entry.images) ? entry.images : [];
305
+ const primary =
306
+ (images as ImageRef[]).find((i) => i.isPrimary) ||
307
+ (images as ImageRef[])[0];
308
+ if (!primary) return null;
309
+ return getBlobObjectURL(primary.blobId);
310
+ }
311
+
312
+ // stub kept for playlistDownloadService compat (returns empty - real data is in docs)
313
+ export async function getSongsWithAudioData(
314
+ _songIds: string[]
315
+ ): Promise<Song[]> {
316
+ console.warn(
317
+ "getsongswithaudiodata: stub - export/import not yet doc-backed"
318
+ );
319
+ return [];
320
+ }
321
+
322
+ // --- mutations ---
323
+
324
+ // create a new playlist doc and add it to the docIndex.
325
+ export async function createPlaylist(fields: {
326
+ title?: string;
327
+ description?: string;
328
+ }): Promise<Playlist> {
329
+ log.trace("playlist.doc", "createPlaylist", fields.title ?? "(untitled)");
330
+ const { docId, handle } = createPlaylistDoc(
331
+ emptyPlaylistDoc({
332
+ title: fields.title ?? "new playlist",
333
+ description: fields.description ?? "",
334
+ })
335
+ );
336
+
337
+ const entry: DocIndexEntry = {
338
+ docId,
339
+ title: fields.title ?? "new playlist",
340
+ addedAt: Date.now(),
341
+ source: "local",
342
+ };
343
+ await addDocIndexEntry(entry);
344
+
345
+ const raw = handle.doc();
346
+ const doc = parsePlaylistDoc(raw ?? {});
347
+ await flushDoc(docId);
348
+ return docToPlaylist(docId, doc);
349
+ }
350
+
351
+ // update playlist metadata (title/description/display filters) via setMetadata mutation.
352
+ export async function updatePlaylist(
353
+ docId: string,
354
+ fields: {
355
+ title?: string;
356
+ description?: string;
357
+ rev?: number;
358
+ bgFilterEnabled?: boolean;
359
+ bgFilterBlur?: number;
360
+ bgFilterContrast?: number;
361
+ bgFilterBrightness?: number;
362
+ coverFilterEnabled?: boolean;
363
+ coverFilterBlur?: number;
364
+ bgSize?: string;
365
+ bgPosition?: string;
366
+ bgRepeat?: string;
367
+ }
368
+ ): Promise<void> {
369
+ log.trace("playlist.doc", "updatePlaylist", docId);
370
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
371
+ const { rev: _rev, ...metadataFields } = toPlain(fields);
372
+ handle.change((doc) => setMetadata(doc, metadataFields));
373
+ await flushDoc(docId as AutomergeUrl);
374
+ // update docIndex title if title changed
375
+ if (fields.title !== undefined) {
376
+ log.trace("playlist.doc", "updatePlaylist: title changed, updating docIndex");
377
+ const existing = await getDocIndexEntry(docId);
378
+ if (existing) {
379
+ await addDocIndexEntry({ ...existing, title: fields.title });
380
+ }
381
+ }
382
+ }
383
+
384
+ // collect all sha256 refs across all docs except the given one.
385
+ // used for blob GC before deleting a doc.
386
+ async function shaRefsExcluding(excludeDocId: string): Promise<Set<string>> {
387
+ const entries = await getAllDocIndexEntries();
388
+ const refs = new Set<string>();
389
+ await Promise.allSettled(
390
+ entries
391
+ .filter((e) => e.docId !== excludeDocId)
392
+ .map(async (e) => {
393
+ try {
394
+ const h = await findPlaylistDoc(e.docId as AutomergeUrl);
395
+ const raw = h.doc();
396
+ if (!raw) return;
397
+ const doc = parsePlaylistDoc(raw);
398
+ for (const song of Object.values(doc.songs)) {
399
+ if (song?.sha256) refs.add(song.sha256);
400
+ for (const img of song?.images ?? []) refs.add(img.blobId);
401
+ }
402
+ for (const img of doc.images ?? []) refs.add(img.blobId);
403
+ } catch { /* ignore unavailable docs */ }
404
+ })
405
+ );
406
+ return refs;
407
+ }
408
+
409
+ // tombstone and remove a playlist doc from the local repo and docIndex.
410
+ export async function deletePlaylist(docId: string): Promise<void> {
411
+ // collect sha refs from the doc being deleted before it's gone
412
+ let deletedShas: string[] = [];
413
+ try {
414
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
415
+ const raw = handle.doc();
416
+ if (raw) {
417
+ const doc = parsePlaylistDoc(raw);
418
+ for (const song of Object.values(doc.songs)) {
419
+ if (song?.sha256) deletedShas.push(song.sha256);
420
+ for (const img of song?.images ?? []) deletedShas.push(img.blobId);
421
+ }
422
+ for (const img of doc.images ?? []) deletedShas.push(img.blobId);
423
+ }
424
+ } catch { /* best-effort */ }
425
+
426
+ await deletePlaylistDoc(docId as AutomergeUrl);
427
+ await removeDocIndexEntry(docId);
428
+ // clear all songs for this doc from the registry
429
+ for (const [id, reg] of songRegistry.entries()) {
430
+ if (reg.docId === docId) {
431
+ songRegistry.delete(id);
432
+ }
433
+ }
434
+
435
+ // gc: delete blobs not referenced by any other playlist
436
+ if (deletedShas.length > 0) {
437
+ const stillReferenced = await shaRefsExcluding(docId);
438
+ await Promise.allSettled(
439
+ deletedShas
440
+ .filter((sha) => !stillReferenced.has(sha))
441
+ .map((sha) => deleteBlob(sha))
442
+ );
443
+ }
444
+ }
445
+
446
+ // add a song to a playlist doc.
447
+ // audio bytes are stored in the blob store; the doc carries only metadata + sha256.
448
+ export async function forkPlaylist(docId: string): Promise<Playlist> {
449
+ const sourceHandle = await findPlaylistDoc(docId as AutomergeUrl);
450
+ const raw = sourceHandle.doc();
451
+ const sourceDoc = parsePlaylistDoc(raw ?? {});
452
+
453
+ // build a fresh doc from the snapshot - strip peer/acl maps so it's fully local.
454
+ // filter out undefined fields so automerge doesn't reject them (it does not
455
+ // allow undefined values; emptyPlaylistDoc's defaults fill any gaps).
456
+ const overrides = Object.fromEntries(
457
+ Object.entries({
458
+ title: sourceDoc.title,
459
+ description: sourceDoc.description,
460
+ images: sourceDoc.images,
461
+ urls: sourceDoc.urls,
462
+ songs: sourceDoc.songs,
463
+ order: sourceDoc.order,
464
+ bgFilterEnabled: sourceDoc.bgFilterEnabled,
465
+ bgFilterBlur: sourceDoc.bgFilterBlur,
466
+ bgFilterContrast: sourceDoc.bgFilterContrast,
467
+ bgFilterBrightness: sourceDoc.bgFilterBrightness,
468
+ coverFilterEnabled: sourceDoc.coverFilterEnabled,
469
+ coverFilterBlur: sourceDoc.coverFilterBlur,
470
+ bgSize: sourceDoc.bgSize,
471
+ bgPosition: sourceDoc.bgPosition,
472
+ bgRepeat: sourceDoc.bgRepeat,
473
+ // do not copy peers/acl/sharingMode - this is a local fork
474
+ }).filter(([, v]) => v !== undefined)
475
+ );
476
+ const seed = emptyPlaylistDoc(overrides);
477
+ const { docId: newDocId, handle } = createPlaylistDoc(seed);
478
+
479
+ await addDocIndexEntry({
480
+ docId: newDocId,
481
+ title: sourceDoc.title || "forked playlist",
482
+ addedAt: Date.now(),
483
+ source: "local",
484
+ });
485
+
486
+ // mark the original docIndex entry as forked so the UI knows
487
+ const existing = await getDocIndexEntry(docId);
488
+ if (existing) {
489
+ await addDocIndexEntry({ ...existing, isForked: true });
490
+ }
491
+
492
+ const newDoc = parsePlaylistDoc(handle.doc() ?? {});
493
+ await flushDoc(newDocId as AutomergeUrl);
494
+ registerDocSongs(newDocId, newDoc);
495
+ return docToPlaylist(newDocId, newDoc);
496
+ }
497
+
498
+ export async function addSongToPlaylist(
499
+ docId: string,
500
+ file: File,
501
+ metadata: {
502
+ title?: string;
503
+ artist?: string;
504
+ album?: string;
505
+ duration?: number;
506
+ imageData?: ArrayBuffer;
507
+ imageType?: string;
508
+ } = {}
509
+ ): Promise<Song> {
510
+ const songId = crypto.randomUUID();
511
+
512
+ // store audio bytes in blob store
513
+ const audioBlob = new Blob([await file.arrayBuffer()], { type: file.type });
514
+ const sha256 = await storeBlob(audioBlob, file.type);
515
+
516
+ // store cover art in blob store if provided
517
+ const imageRefs: ImageRef[] = [];
518
+ if (metadata.imageData && metadata.imageType) {
519
+ const imageBlob = new Blob([metadata.imageData], {
520
+ type: metadata.imageType,
521
+ });
522
+ const imageSha = await storeBlob(imageBlob, metadata.imageType);
523
+ imageRefs.push({ blobId: imageSha, isPrimary: true, blobType: "original" });
524
+ }
525
+
526
+ const entry: SongEntry = {
527
+ id: songId,
528
+ title: metadata.title || file.name.replace(/\.[^/.]+$/, "") || "untitled",
529
+ artist: metadata.artist || "unknown artist",
530
+ album: metadata.album || "unknown album",
531
+ duration: metadata.duration || 0,
532
+ mimeType: file.type || "audio/mpeg",
533
+ fileSize: file.size,
534
+ sha256,
535
+ images: imageRefs,
536
+ urls: [],
537
+ };
538
+
539
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
540
+
541
+ // dedup: if a song with this sha already exists in the doc, return it
542
+ const existingRaw = handle.doc();
543
+ const existingDoc = parsePlaylistDoc(existingRaw ?? {});
544
+ const dupId = Object.keys(existingDoc.songs).find(
545
+ (id) => existingDoc.songs[id]?.sha256 === sha256
546
+ );
547
+ if (dupId) {
548
+ log.debug("playlist.doc", "addSongToPlaylist: dedup, sha already in doc", sha256);
549
+ const dupIndex = existingDoc.order.indexOf(dupId);
550
+ return hydrateSongImage(
551
+ songEntryToSong(existingDoc.songs[dupId]!, docId, dupIndex >= 0 ? dupIndex : 0)
552
+ );
553
+ }
554
+
555
+ handle.change((doc) => upsertSong(doc, toPlain(entry)));
556
+ await flushDoc(docId as AutomergeUrl);
557
+
558
+ // update registry
559
+ const raw = handle.doc();
560
+ const doc = parsePlaylistDoc(raw ?? {});
561
+ registerDocSongs(docId, doc);
562
+
563
+ const index = doc.order.indexOf(songId);
564
+ const song = songEntryToSong(entry, docId, index >= 0 ? index : doc.order.length - 1);
565
+ triggerSpecificSongUpdate(songId);
566
+ return song;
567
+ }
568
+
569
+ // update song metadata in the doc.
570
+ // only title, artist, album, duration, and image fields are supported.
571
+ export async function updateSongInDoc(
572
+ docId: string,
573
+ songId: string,
574
+ updates: Partial<Pick<Song, "title" | "artist" | "album" | "duration" | "imageData" | "imageType">>
575
+ ): Promise<void> {
576
+ log.trace("playlist.doc", "updateSongInDoc", docId, songId);
577
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
578
+
579
+ // store new image if provided
580
+ let newImageRef: ImageRef | undefined;
581
+ if (updates.imageData && updates.imageType) {
582
+ const imageBlob = new Blob([updates.imageData], {
583
+ type: updates.imageType,
584
+ });
585
+ const imageSha = await storeBlob(imageBlob, updates.imageType);
586
+ newImageRef = { blobId: imageSha, isPrimary: true, blobType: "original" };
587
+ }
588
+
589
+ // plain scalar copies - never let solid proxies into the doc
590
+ const title = updates.title;
591
+ const artist = updates.artist;
592
+ const album = updates.album;
593
+ const duration = updates.duration;
594
+
595
+ handle.change((doc) => {
596
+ const existing = doc.songs[songId];
597
+ if (!existing) return;
598
+
599
+ // mutate fields in place - re-inserting a doc-derived object (e.g. via
600
+ // spread + upsertSong) makes automerge throw "Cannot create a reference
601
+ // to an existing document object"
602
+ if (title !== undefined) existing.title = title;
603
+ if (artist !== undefined) existing.artist = artist;
604
+ if (album !== undefined) existing.album = album;
605
+ if (duration !== undefined) existing.duration = duration;
606
+
607
+ if (newImageRef) {
608
+ // replace all images with the new one (fresh plain object)
609
+ existing.images.splice(0, existing.images.length);
610
+ existing.images.push(toPlain(newImageRef));
611
+ }
612
+ });
613
+ await flushDoc(docId as AutomergeUrl);
614
+
615
+ // refresh registry
616
+ const raw = handle.doc();
617
+ const doc = parsePlaylistDoc(raw ?? {});
618
+ registerDocSongs(docId, doc);
619
+
620
+ triggerSpecificSongUpdate(songId);
621
+ }
622
+
623
+ // remove a song from the playlist doc.
624
+ // the audio blob is not deleted (may be shared or still needed for export).
625
+ export async function deleteSong(
626
+ docId: string,
627
+ songId: string
628
+ ): Promise<void> {
629
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
630
+ handle.change((doc) => removeSong(doc, songId));
631
+ await flushDoc(docId as AutomergeUrl);
632
+ songRegistry.delete(songId);
633
+ triggerSpecificSongUpdate(songId);
634
+ }
635
+
636
+ // reorder songs in a playlist doc by moving fromIndex to toIndex.
637
+ export async function reorderSongsInDoc(
638
+ docId: string,
639
+ fromIndex: number,
640
+ toIndex: number
641
+ ): Promise<void> {
642
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
643
+ handle.change((doc) => {
644
+ const songId = doc.order[fromIndex];
645
+ if (songId === undefined) return;
646
+ reorderSongsMutation(doc, songId, toIndex);
647
+ });
648
+ await flushDoc(docId as AutomergeUrl);
649
+
650
+ // refresh registry with updated order
651
+ const raw = handle.doc();
652
+ const doc = parsePlaylistDoc(raw ?? {});
653
+ registerDocSongs(docId, doc);
654
+ }
655
+
656
+ // add a cover image to a playlist doc.
657
+ // imageData bytes are stored in the blob store; an ImageRef is added to the doc.
658
+ export async function setPlaylistCoverImage(
659
+ docId: string,
660
+ imageData: ArrayBuffer,
661
+ mimeType: string
662
+ ): Promise<void> {
663
+ const imageBlob = new Blob([imageData], { type: mimeType });
664
+ const sha256 = await storeBlob(imageBlob, mimeType);
665
+
666
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
667
+ handle.change((doc) => {
668
+ addImage(doc, { blobId: sha256, isPrimary: true, blobType: "original" });
669
+ });
670
+ await flushDoc(docId as AutomergeUrl);
671
+ }
672
+
673
+ // remove all playlist-level cover images from the doc.
674
+ // blob store bytes are not deleted (they may be referenced elsewhere).
675
+ export async function clearPlaylistCoverImage(docId: string): Promise<void> {
676
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
677
+ handle.change((doc) => {
678
+ doc.images.splice(0, doc.images.length);
679
+ });
680
+ await flushDoc(docId as AutomergeUrl);
681
+ }
682
+
683
+ // add or update a song's cover image.
684
+ export async function setSongCoverImage(
685
+ docId: string,
686
+ songId: string,
687
+ imageData: ArrayBuffer,
688
+ mimeType: string
689
+ ): Promise<void> {
690
+ const imageBlob = new Blob([imageData], { type: mimeType });
691
+ const sha256 = await storeBlob(imageBlob, mimeType);
692
+
693
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
694
+ handle.change((doc) => {
695
+ addImage(
696
+ doc,
697
+ { blobId: sha256, isPrimary: true, blobType: "original" },
698
+ { songId }
699
+ );
700
+ });
701
+ await flushDoc(docId as AutomergeUrl);
702
+
703
+ triggerSpecificSongUpdate(songId);
704
+ }
705
+
706
+ // expose calculateSHA256 re-export for callers that already have the bytes
707
+ export { calculateSHA256 };