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