@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,389 @@
1
+ import type { Playlist, Song } from "../types/playlist.js";
2
+ import type { AutomergeUrl } from "@automerge/automerge-repo";
3
+ import {
4
+ getSongsForPlaylist,
5
+ updatePlaylist,
6
+ docToPlaylist,
7
+ } from "./playlistDocService.js";
8
+ import { findPlaylistDoc } from "./automergeRepo.js";
9
+ import { parsePlaylistDoc } from "@freqhole/api-client/playlistz";
10
+ import JSZip from "jszip";
11
+ import { getBlob } from "@freqhole/api-client/storage";
12
+ import { buildPlaylistZip, cleanupOpfsTempFile } from "../zip-bundle/zipBuilder.js";
13
+ import type { PlaylistZipEntry, PlaylistZipOptions } from "../zip-bundle/types.js";
14
+
15
+ export type PlaylistDownloadOptions = PlaylistZipOptions;
16
+
17
+ // fetches a blob from IDB by sha256 key.
18
+ async function idbBlobFetcher(sha256: string): Promise<ArrayBuffer | undefined> {
19
+ const blob = await getBlob(sha256);
20
+ return blob?.arrayBuffer();
21
+ }
22
+
23
+ // builds a PlaylistZipEntry from a Playlist and its songs.
24
+ function toZipEntry(playlist: Playlist, songs: Song[]): PlaylistZipEntry {
25
+ return {
26
+ playlist: {
27
+ id: playlist.id,
28
+ title: playlist.title,
29
+ description: playlist.description,
30
+ rev: playlist.rev,
31
+ imageSha: playlist._primaryImageSha,
32
+ imageType: playlist.imageType,
33
+ bgFilterEnabled: playlist.bgFilterEnabled,
34
+ bgFilterBlur: playlist.bgFilterBlur,
35
+ bgFilterContrast: playlist.bgFilterContrast,
36
+ bgFilterBrightness: playlist.bgFilterBrightness,
37
+ coverFilterEnabled: playlist.coverFilterEnabled,
38
+ coverFilterBlur: playlist.coverFilterBlur,
39
+ },
40
+ songs: songs.map((song) => {
41
+ const primaryImage =
42
+ song.images?.find((i) => i.isPrimary) ?? song.images?.[0];
43
+ return {
44
+ id: song.id,
45
+ title: song.title,
46
+ artist: song.artist,
47
+ album: song.album,
48
+ duration: song.duration ?? 0,
49
+ originalFilename: song.originalFilename ?? "",
50
+ mimeType: song.mimeType ?? "audio/mpeg",
51
+ fileSize: song.fileSize,
52
+ sha: song.sha ?? song.sha256,
53
+ imageSha: primaryImage?.blobId,
54
+ imageType: song.imageType,
55
+ };
56
+ }),
57
+ };
58
+ }
59
+
60
+ // triggers a browser file download for the given blob, then cleans up any
61
+ // OPFS temp file that buildPlaylistZip may have used as its write target.
62
+ function triggerDownload(blob: Blob, filename: string): void {
63
+ const url = URL.createObjectURL(blob);
64
+ const a = document.createElement("a");
65
+ a.href = url;
66
+ a.download = filename;
67
+ a.click();
68
+ // revoke after a tick so the browser has time to start the download
69
+ setTimeout(() => {
70
+ URL.revokeObjectURL(url);
71
+ // if blob is an OPFS File, clean up the temp entry by name
72
+ if ("name" in blob && typeof (blob as File).name === "string") {
73
+ void cleanupOpfsTempFile((blob as File).name);
74
+ }
75
+ }, 1000);
76
+ }
77
+
78
+ // downloads a playlist as a zip file containing all songs, metadata, and images.
79
+ // audio and image bytes are fetched from the blob store keyed by sha256.
80
+ export async function downloadPlaylistAsZip(
81
+ playlist: Playlist,
82
+ options: PlaylistDownloadOptions = {
83
+ includeImages: true,
84
+ generateM3U: true,
85
+ includeHTML: true,
86
+ }
87
+ ): Promise<void> {
88
+ // increment revision so reimports can detect updates
89
+ const newRev = (playlist.rev ?? 0) + 1;
90
+ await updatePlaylist(playlist.id, { rev: newRev });
91
+
92
+ // re-read _primaryImageSha from the live doc - the signal state may be stale
93
+ // if the cover was just set and selectPlaylist was called with raw imageData
94
+ // (which lacks _primaryImageSha)
95
+ const handle = await findPlaylistDoc(playlist.id as AutomergeUrl);
96
+ const raw = handle.doc();
97
+ const docPlaylist = raw ? docToPlaylist(playlist.id, parsePlaylistDoc(raw)) : null;
98
+ const updatedPlaylist = {
99
+ ...playlist,
100
+ rev: newRev,
101
+ _primaryImageSha: docPlaylist?._primaryImageSha ?? playlist._primaryImageSha,
102
+ imageType: playlist.imageType ?? docPlaylist?.imageType,
103
+ };
104
+
105
+ const songs = await getSongsForPlaylist(playlist.id);
106
+ const entry = toZipEntry(updatedPlaylist, songs);
107
+
108
+ // find the embedded bundle url for the standalone app shell
109
+ const scriptEl = Array.from(document.querySelectorAll("script[src]")).find(
110
+ (el) => (el as HTMLScriptElement).src.includes("freqhole-playlistz.js")
111
+ ) as HTMLScriptElement | undefined;
112
+ const appBundleUrl =
113
+ scriptEl?.src ?? `${window.location.origin}/freqhole-playlistz.js`;
114
+
115
+ const zipBlob = await buildPlaylistZip(entry, idbBlobFetcher, {
116
+ ...options,
117
+ appBundleUrl,
118
+ });
119
+
120
+ const safeTitle = entry.playlist.title.replace(/[^a-zA-Z0-9_-]/g, "_") || "playlist";
121
+ triggerDownload(zipBlob, `${safeTitle}.zip`);
122
+ }
123
+
124
+ // types for imported playlist data that may not match our exact schema
125
+ interface ImportedPlaylistData {
126
+ playlist?: {
127
+ id?: string;
128
+ title?: string;
129
+ description?: string;
130
+ imageData?: ArrayBuffer;
131
+ thumbnailData?: ArrayBuffer;
132
+ imageType?: string;
133
+ createdAt?: string;
134
+ updatedAt?: string;
135
+ rev?: number;
136
+ songCount?: number;
137
+ totalDuration?: number;
138
+ imageExtension?: string | null;
139
+ imageMimeType?: string | null;
140
+ imageBase64?: string;
141
+ };
142
+ songs?: ImportedSongMetadata[];
143
+ }
144
+
145
+ interface ImportedSongMetadata {
146
+ id?: string;
147
+ title?: string;
148
+ artist?: string;
149
+ album?: string;
150
+ duration?: number;
151
+ originalFilename?: string;
152
+ safeFilename?: string;
153
+ fileSize?: number;
154
+ mimeType?: string;
155
+ position?: number;
156
+ imageData?: ArrayBuffer;
157
+ thumbnailData?: ArrayBuffer;
158
+ imageType?: string;
159
+ imageMimeType?: string | null;
160
+ imageBase64?: string;
161
+ createdAt?: number;
162
+ updatedAt?: number;
163
+ playlistId?: string;
164
+ standaloneFilePath?: string;
165
+ sha?: string;
166
+ }
167
+
168
+ // parses an uploaded zip file and extracts playlist data.
169
+ // returns metadata and song objects with inline audioData for the caller to store.
170
+ export async function parsePlaylistZip(file: File): Promise<{
171
+ playlist: Omit<Playlist, "id" | "createdAt" | "updatedAt">;
172
+ songs: Omit<Song, "id" | "createdAt" | "updatedAt" | "playlistId">[];
173
+ }> {
174
+ try {
175
+ const zip = new JSZip();
176
+ const zipContent = await zip.loadAsync(file);
177
+
178
+ let playlistInfo: ImportedPlaylistData["playlist"] | null = null;
179
+ let playlistImageData: ArrayBuffer | undefined;
180
+ let playlistImageType: string | undefined;
181
+ const songs: Omit<Song, "id" | "createdAt" | "updatedAt" | "playlistId">[] =
182
+ [];
183
+
184
+ // parse playlist metadata - try new format first, then fall back to old
185
+ let playlistData: ImportedPlaylistData | null = null;
186
+ let playlistJsonFiles = zipContent.file(/^[^/]+\/data\/playlist\.json$/i);
187
+ if (playlistJsonFiles.length === 0) {
188
+ const playlistJsonFile = zipContent.file("data/playlist.json");
189
+ if (playlistJsonFile) {
190
+ playlistJsonFiles = [playlistJsonFile];
191
+ }
192
+ }
193
+
194
+ if (playlistJsonFiles.length > 0) {
195
+ const playlistContent = await playlistJsonFiles[0]!.async("string");
196
+ playlistData = JSON.parse(playlistContent);
197
+ playlistInfo = playlistData?.playlist || null;
198
+ } else {
199
+ const playlistInfoFile = zipContent.file("playlist-info.json");
200
+ if (playlistInfoFile) {
201
+ const infoContent = await playlistInfoFile.async("string");
202
+ playlistInfo = JSON.parse(infoContent);
203
+ }
204
+ }
205
+
206
+ // find playlist cover image
207
+ let coverFiles = zipContent.file(
208
+ /^[^/]+\/data\/playlist-cover\.(jpg|jpeg|png|gif|webp)$/i
209
+ );
210
+ if (coverFiles.length === 0) {
211
+ coverFiles = zipContent.file(
212
+ /^data\/playlist-cover\.(jpg|jpeg|png|gif|webp)$/i
213
+ );
214
+ }
215
+ if (coverFiles.length === 0) {
216
+ coverFiles = zipContent.file(
217
+ /^playlist-cover\.(jpg|jpeg|png|gif|webp)$/i
218
+ );
219
+ }
220
+ if (coverFiles.length > 0) {
221
+ playlistImageData = await coverFiles[0]!.async("arraybuffer");
222
+ playlistImageType = getMimeTypeFromExtension(coverFiles[0]!.name);
223
+ } else if (playlistData && playlistData.playlist?.imageBase64) {
224
+ playlistImageData = base64ToArrayBuffer(
225
+ playlistData.playlist.imageBase64
226
+ );
227
+ playlistImageType = playlistData.playlist.imageMimeType || undefined;
228
+ }
229
+
230
+ // parse m3u file if present (for song order metadata)
231
+ const m3uFiles = zipContent.file(/\.m3u8?$/i);
232
+ if (m3uFiles.length > 0) {
233
+ await m3uFiles[0]!.async("string");
234
+ }
235
+
236
+ // extract audio files from data folder or root
237
+ let songFiles = zipContent.file(
238
+ /^[^/]+\/data\/[^/]+\.(mp3|m4a|wav|flac|ogg|webm)$/i
239
+ );
240
+ if (songFiles.length === 0) {
241
+ songFiles = zipContent.file(
242
+ /^data\/[^/]+\.(mp3|m4a|wav|flac|ogg|webm)$/i
243
+ );
244
+ }
245
+ if (songFiles.length === 0) {
246
+ songFiles = zipContent.file(/^[^/]+\.(mp3|m4a|wav|flac|ogg|webm)$/i);
247
+ }
248
+
249
+ for (const songFile of songFiles) {
250
+ const audioData = await songFile.async("arraybuffer");
251
+ const fileName = songFile.name.split("/").pop() || "";
252
+ const baseName = fileName.replace(/\.[^.]+$/, "");
253
+
254
+ // look up metadata from playlist.json
255
+ let songMetadata: Partial<ImportedSongMetadata> = {};
256
+ if (playlistData && playlistData.songs) {
257
+ const songData = playlistData.songs.find(
258
+ (s: ImportedSongMetadata) =>
259
+ s.safeFilename === fileName || s.originalFilename === fileName
260
+ );
261
+ if (songData) {
262
+ songMetadata = {
263
+ id: songData.id,
264
+ title: songData.title,
265
+ artist: songData.artist,
266
+ album: songData.album,
267
+ duration: songData.duration,
268
+ originalFilename: songData.originalFilename,
269
+ imageBase64: songData.imageBase64,
270
+ imageMimeType: songData.imageMimeType,
271
+ };
272
+ }
273
+ } else {
274
+ const metadataFile = zipContent.file(`${baseName}-metadata.json`);
275
+ if (metadataFile) {
276
+ const metadataContent = await metadataFile.async("string");
277
+ songMetadata = JSON.parse(metadataContent);
278
+ }
279
+ }
280
+
281
+ // find cover image for this song
282
+ let imageData: ArrayBuffer | undefined;
283
+ let imageType: string | undefined;
284
+
285
+ if (songMetadata.imageBase64) {
286
+ imageData = base64ToArrayBuffer(songMetadata.imageBase64);
287
+ imageType = songMetadata.imageMimeType || undefined;
288
+ } else {
289
+ let imageFiles = zipContent.file(
290
+ new RegExp(
291
+ `^[^/]+/data/${baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-cover\\.(jpg|jpeg|png|gif|webp)$`,
292
+ "i"
293
+ )
294
+ );
295
+ if (imageFiles.length === 0) {
296
+ imageFiles = zipContent.file(
297
+ new RegExp(
298
+ `^data/${baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-cover\\.(jpg|jpeg|png|gif|webp)$`,
299
+ "i"
300
+ )
301
+ );
302
+ }
303
+ if (imageFiles.length === 0) {
304
+ imageFiles = zipContent.file(
305
+ new RegExp(
306
+ `^${baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-cover\\.(jpg|jpeg|png|gif|webp)$`,
307
+ "i"
308
+ )
309
+ );
310
+ }
311
+ if (imageFiles.length > 0) {
312
+ imageData = await imageFiles[0]!.async("arraybuffer");
313
+ imageType = getMimeTypeFromExtension(imageFiles[0]!.name);
314
+ }
315
+ }
316
+
317
+ const [artist, title] = baseName.includes(" - ")
318
+ ? baseName.split(" - ", 2)
319
+ : ["Unknown Artist", baseName];
320
+
321
+ const song: Omit<Song, "id" | "createdAt" | "updatedAt" | "playlistId"> =
322
+ {
323
+ audioData,
324
+ mimeType: getMimeTypeFromExtension(fileName),
325
+ originalFilename: songMetadata.originalFilename || fileName,
326
+ title: songMetadata.title || title!.replace(/_/g, " "),
327
+ artist: songMetadata.artist || artist!.replace(/_/g, " "),
328
+ album: songMetadata.album || "Unknown Album",
329
+ duration: songMetadata.duration || 0,
330
+ position: songs.length,
331
+ imageData,
332
+ imageType,
333
+ };
334
+
335
+ songs.push(song);
336
+ }
337
+
338
+ const resultPlaylist: Omit<Playlist, "id" | "createdAt" | "updatedAt"> = {
339
+ title: playlistInfo?.title || file.name.replace(/\.zip$/i, ""),
340
+ description: playlistInfo?.description || "",
341
+ imageData: playlistImageData,
342
+ imageType: playlistImageType,
343
+ songIds: [],
344
+ };
345
+
346
+ return { playlist: resultPlaylist, songs };
347
+ } catch (error) {
348
+ console.error("error parsing playlist zip:", error);
349
+ if (
350
+ error instanceof Error &&
351
+ (error.message.includes("Corrupted ZIP") ||
352
+ error.message.includes("Invalid JSON") ||
353
+ error.message.includes("Missing playlist"))
354
+ ) {
355
+ throw error;
356
+ }
357
+ throw new Error("Failed to parse playlist ZIP file");
358
+ }
359
+ }
360
+
361
+ // gets mime type from file extension
362
+ function getMimeTypeFromExtension(fileName: string): string {
363
+ const extension = fileName.toLowerCase().split(".").pop();
364
+ const mimeTypes: { [key: string]: string } = {
365
+ mp3: "audio/mpeg",
366
+ m4a: "audio/mp4",
367
+ wav: "audio/wav",
368
+ flac: "audio/flac",
369
+ ogg: "audio/ogg",
370
+ webm: "audio/webm",
371
+ jpg: "image/jpeg",
372
+ jpeg: "image/jpeg",
373
+ png: "image/png",
374
+ gif: "image/gif",
375
+ webp: "image/webp",
376
+ };
377
+
378
+ return mimeTypes[extension || ""] || "application/octet-stream";
379
+ }
380
+
381
+ // converts a base64 string to an ArrayBuffer
382
+ function base64ToArrayBuffer(base64: string): ArrayBuffer {
383
+ const binaryString = atob(base64);
384
+ const bytes = new Uint8Array(binaryString.length);
385
+ for (let i = 0; i < binaryString.length; i++) {
386
+ bytes[i] = binaryString.charCodeAt(i);
387
+ }
388
+ return bytes.buffer;
389
+ }