@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,674 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import JSZip from "jszip";
|
|
3
|
+
import {
|
|
4
|
+
downloadPlaylistAsZip,
|
|
5
|
+
parsePlaylistZip,
|
|
6
|
+
type PlaylistDownloadOptions,
|
|
7
|
+
} from "./playlistDownloadService.js";
|
|
8
|
+
import type { Playlist, Song } from "../types/playlist.js";
|
|
9
|
+
|
|
10
|
+
// mock dependencies
|
|
11
|
+
vi.mock("./playlistDocService.js", () => ({
|
|
12
|
+
getSongsForPlaylist: vi.fn(),
|
|
13
|
+
updatePlaylist: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("@freqhole/api-client/storage", () => ({
|
|
17
|
+
getBlob: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("../utils/standaloneTemplates.js", () => ({
|
|
21
|
+
generatePlaylistzJs: vi.fn(() => "(function(){})();\n"),
|
|
22
|
+
generateIndexHtml: vi.fn(() => "<html></html>"),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock("../utils/swTemplate.js", () => ({
|
|
26
|
+
generateSwJs: vi.fn(() => "// sw"),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("../utils/m3u.js", () => ({
|
|
30
|
+
generateM3UContent: vi.fn(() => "#EXTM3U\n"),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// mock window for browser apis
|
|
34
|
+
Object.defineProperty(global, "window", {
|
|
35
|
+
value: {
|
|
36
|
+
location: {
|
|
37
|
+
href: "http://localhost:3000",
|
|
38
|
+
origin: "http://localhost:3000",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
writable: true,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// mock jszip
|
|
45
|
+
vi.mock("jszip", () => ({
|
|
46
|
+
default: vi.fn(() => ({
|
|
47
|
+
file: vi.fn(),
|
|
48
|
+
folder: vi.fn().mockReturnThis(),
|
|
49
|
+
generateAsync: vi.fn().mockResolvedValue(new Blob(["mock zip content"])),
|
|
50
|
+
loadAsync: vi.fn().mockResolvedValue({
|
|
51
|
+
file: vi.fn((pattern) => {
|
|
52
|
+
if (typeof pattern === "string") {
|
|
53
|
+
return pattern === "data/playlist.json" ||
|
|
54
|
+
pattern === "playlist-info.json"
|
|
55
|
+
? {
|
|
56
|
+
async: vi.fn().mockResolvedValue(
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
playlist: {
|
|
59
|
+
title: "Test Playlist",
|
|
60
|
+
description: "Test Description",
|
|
61
|
+
},
|
|
62
|
+
songs: [],
|
|
63
|
+
})
|
|
64
|
+
),
|
|
65
|
+
}
|
|
66
|
+
: null;
|
|
67
|
+
} else if (pattern instanceof RegExp) {
|
|
68
|
+
if (pattern.test("playlist.json")) {
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
async: vi.fn().mockResolvedValue(
|
|
72
|
+
JSON.stringify({
|
|
73
|
+
playlist: {
|
|
74
|
+
title: "Test Playlist",
|
|
75
|
+
description: "Test Description",
|
|
76
|
+
},
|
|
77
|
+
songs: [],
|
|
78
|
+
})
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
return [];
|
|
86
|
+
}),
|
|
87
|
+
files: {},
|
|
88
|
+
}),
|
|
89
|
+
files: {},
|
|
90
|
+
})),
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
// mock global objects
|
|
94
|
+
global.URL = {
|
|
95
|
+
createObjectURL: vi.fn(() => "mock-blob-url"),
|
|
96
|
+
revokeObjectURL: vi.fn(),
|
|
97
|
+
} as any;
|
|
98
|
+
|
|
99
|
+
global.document = {
|
|
100
|
+
createElement: vi.fn(() => ({
|
|
101
|
+
href: "",
|
|
102
|
+
download: "",
|
|
103
|
+
click: vi.fn(),
|
|
104
|
+
})),
|
|
105
|
+
body: {
|
|
106
|
+
appendChild: vi.fn(),
|
|
107
|
+
removeChild: vi.fn(),
|
|
108
|
+
},
|
|
109
|
+
querySelectorAll: vi.fn(() => []),
|
|
110
|
+
} as any;
|
|
111
|
+
|
|
112
|
+
global.fetch = vi.fn().mockRejectedValue(new Error("fetch not available"));
|
|
113
|
+
|
|
114
|
+
describe("Playlist Download Service", () => {
|
|
115
|
+
let mockPlaylist: Playlist;
|
|
116
|
+
let mockSongs: Song[];
|
|
117
|
+
|
|
118
|
+
beforeEach(async () => {
|
|
119
|
+
vi.clearAllMocks();
|
|
120
|
+
|
|
121
|
+
mockPlaylist = {
|
|
122
|
+
id: "playlist-123",
|
|
123
|
+
title: "Test Playlist",
|
|
124
|
+
description: "A test playlist",
|
|
125
|
+
songIds: ["song1", "song2"],
|
|
126
|
+
createdAt: Date.now(),
|
|
127
|
+
updatedAt: Date.now(),
|
|
128
|
+
rev: 1,
|
|
129
|
+
_primaryImageSha: "playlist-image-sha",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
mockSongs = [
|
|
133
|
+
{
|
|
134
|
+
id: "song1",
|
|
135
|
+
title: "Song One",
|
|
136
|
+
artist: "Artist One",
|
|
137
|
+
album: "Album One",
|
|
138
|
+
duration: 180,
|
|
139
|
+
sha: "existing-sha-1",
|
|
140
|
+
images: [{ blobId: "image-sha-1", isPrimary: true, blobType: "original" }],
|
|
141
|
+
mimeType: "audio/mpeg",
|
|
142
|
+
originalFilename: "song-one.mp3",
|
|
143
|
+
position: 0,
|
|
144
|
+
createdAt: Date.now(),
|
|
145
|
+
updatedAt: Date.now(),
|
|
146
|
+
playlistId: "playlist-123",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "song2",
|
|
150
|
+
title: "Song Two",
|
|
151
|
+
artist: "Artist Two",
|
|
152
|
+
album: "Album Two",
|
|
153
|
+
duration: 240,
|
|
154
|
+
sha: "existing-sha-2",
|
|
155
|
+
images: [],
|
|
156
|
+
mimeType: "audio/mp4",
|
|
157
|
+
originalFilename: "song-two.m4a",
|
|
158
|
+
position: 1,
|
|
159
|
+
createdAt: Date.now(),
|
|
160
|
+
updatedAt: Date.now(),
|
|
161
|
+
playlistId: "playlist-123",
|
|
162
|
+
},
|
|
163
|
+
] as Song[];
|
|
164
|
+
|
|
165
|
+
const { getSongsForPlaylist, updatePlaylist } = await import(
|
|
166
|
+
"./playlistDocService.js"
|
|
167
|
+
);
|
|
168
|
+
const { getBlob } = await import("@freqhole/api-client/storage");
|
|
169
|
+
|
|
170
|
+
vi.mocked(getSongsForPlaylist).mockResolvedValue(mockSongs);
|
|
171
|
+
vi.mocked(updatePlaylist).mockResolvedValue(undefined);
|
|
172
|
+
vi.mocked(getBlob).mockImplementation(async (_blobId: string) => {
|
|
173
|
+
// return a blob-like object with arrayBuffer() for the test environment
|
|
174
|
+
return {
|
|
175
|
+
type: "audio/mpeg",
|
|
176
|
+
arrayBuffer: async () => new ArrayBuffer(1000),
|
|
177
|
+
} as unknown as Blob;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
vi.mocked(JSZip).mockImplementation(
|
|
181
|
+
() =>
|
|
182
|
+
({
|
|
183
|
+
file: vi.fn(),
|
|
184
|
+
folder: vi.fn().mockReturnThis(),
|
|
185
|
+
generateAsync: vi
|
|
186
|
+
.fn()
|
|
187
|
+
.mockResolvedValue(new Blob(["mock zip content"])),
|
|
188
|
+
loadAsync: vi.fn(),
|
|
189
|
+
files: {},
|
|
190
|
+
}) as any
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
afterEach(() => {
|
|
195
|
+
vi.restoreAllMocks();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("downloadPlaylistAsZip", () => {
|
|
199
|
+
it("should create a zip file with playlist and songs", async () => {
|
|
200
|
+
const { getSongsForPlaylist, updatePlaylist } = await import(
|
|
201
|
+
"./playlistDocService.js"
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
await downloadPlaylistAsZip(mockPlaylist);
|
|
205
|
+
|
|
206
|
+
expect(vi.mocked(getSongsForPlaylist)).toHaveBeenCalledWith(
|
|
207
|
+
mockPlaylist.id
|
|
208
|
+
);
|
|
209
|
+
expect(vi.mocked(updatePlaylist)).toHaveBeenCalledWith(mockPlaylist.id, {
|
|
210
|
+
rev: 2,
|
|
211
|
+
});
|
|
212
|
+
expect(JSZip).toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("should increment playlist revision before download", async () => {
|
|
216
|
+
const { updatePlaylist } = await import("./playlistDocService.js");
|
|
217
|
+
const playlistWithRev = { ...mockPlaylist, rev: 5 };
|
|
218
|
+
|
|
219
|
+
await downloadPlaylistAsZip(playlistWithRev);
|
|
220
|
+
|
|
221
|
+
expect(vi.mocked(updatePlaylist)).toHaveBeenCalledWith(
|
|
222
|
+
playlistWithRev.id,
|
|
223
|
+
{ rev: 6 }
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should handle playlist without revision", async () => {
|
|
228
|
+
const { updatePlaylist } = await import("./playlistDocService.js");
|
|
229
|
+
const playlistNoRev = { ...mockPlaylist, rev: undefined };
|
|
230
|
+
|
|
231
|
+
await downloadPlaylistAsZip(playlistNoRev);
|
|
232
|
+
|
|
233
|
+
expect(vi.mocked(updatePlaylist)).toHaveBeenCalledWith(
|
|
234
|
+
playlistNoRev.id,
|
|
235
|
+
{ rev: 1 }
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("should fetch audio from blob store for each song", async () => {
|
|
240
|
+
const { getBlob } = await import("@freqhole/api-client/storage");
|
|
241
|
+
|
|
242
|
+
await downloadPlaylistAsZip(mockPlaylist);
|
|
243
|
+
|
|
244
|
+
// should call getBlob for each song's sha
|
|
245
|
+
expect(vi.mocked(getBlob)).toHaveBeenCalledWith("existing-sha-1");
|
|
246
|
+
expect(vi.mocked(getBlob)).toHaveBeenCalledWith("existing-sha-2");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("should fetch playlist cover image from blob store", async () => {
|
|
250
|
+
const { getBlob } = await import("@freqhole/api-client/storage");
|
|
251
|
+
|
|
252
|
+
await downloadPlaylistAsZip(mockPlaylist);
|
|
253
|
+
|
|
254
|
+
expect(vi.mocked(getBlob)).toHaveBeenCalledWith("playlist-image-sha");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("should skip image fetching when includeImages is false", async () => {
|
|
258
|
+
const { getBlob } = await import("@freqhole/api-client/storage");
|
|
259
|
+
|
|
260
|
+
await downloadPlaylistAsZip(mockPlaylist, { includeImages: false });
|
|
261
|
+
|
|
262
|
+
// should not call getBlob for image sha
|
|
263
|
+
expect(vi.mocked(getBlob)).not.toHaveBeenCalledWith("image-sha-1");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should handle empty playlist", async () => {
|
|
267
|
+
const emptyPlaylist = { ...mockPlaylist, songIds: [] };
|
|
268
|
+
const { getSongsForPlaylist } = await import("./playlistDocService.js");
|
|
269
|
+
vi.mocked(getSongsForPlaylist).mockResolvedValue([]);
|
|
270
|
+
|
|
271
|
+
await downloadPlaylistAsZip(emptyPlaylist);
|
|
272
|
+
|
|
273
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("should handle songs without sha (no audio in blob store)", async () => {
|
|
277
|
+
const songsWithoutSha = [
|
|
278
|
+
{
|
|
279
|
+
...mockSongs[0]!,
|
|
280
|
+
sha: undefined,
|
|
281
|
+
sha256: undefined,
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
const { getSongsForPlaylist } = await import("./playlistDocService.js");
|
|
285
|
+
vi.mocked(getSongsForPlaylist).mockResolvedValue(songsWithoutSha as Song[]);
|
|
286
|
+
|
|
287
|
+
await downloadPlaylistAsZip(mockPlaylist);
|
|
288
|
+
|
|
289
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("should trigger download in browser", async () => {
|
|
293
|
+
const mockAnchorElement = {
|
|
294
|
+
href: "",
|
|
295
|
+
download: "",
|
|
296
|
+
click: vi.fn(),
|
|
297
|
+
};
|
|
298
|
+
vi.mocked(document.createElement).mockReturnValue(mockAnchorElement as any);
|
|
299
|
+
|
|
300
|
+
await downloadPlaylistAsZip(mockPlaylist);
|
|
301
|
+
|
|
302
|
+
expect(document.createElement).toHaveBeenCalledWith("a");
|
|
303
|
+
expect(mockAnchorElement.click).toHaveBeenCalled();
|
|
304
|
+
expect(document.body.appendChild).toHaveBeenCalledWith(mockAnchorElement);
|
|
305
|
+
expect(document.body.removeChild).toHaveBeenCalledWith(mockAnchorElement);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("should handle ZIP generation errors", async () => {
|
|
309
|
+
vi.mocked(JSZip).mockImplementation(() => {
|
|
310
|
+
throw new Error("ZIP generation failed");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await expect(downloadPlaylistAsZip(mockPlaylist)).rejects.toThrow(
|
|
314
|
+
"ZIP generation failed"
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("should handle updatePlaylist errors", async () => {
|
|
319
|
+
const { updatePlaylist } = await import("./playlistDocService.js");
|
|
320
|
+
vi.mocked(updatePlaylist).mockRejectedValue(new Error("Database error"));
|
|
321
|
+
|
|
322
|
+
await expect(downloadPlaylistAsZip(mockPlaylist)).rejects.toThrow(
|
|
323
|
+
"Database error"
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("should include metadata when option is enabled", async () => {
|
|
328
|
+
const options = { includeImages: true };
|
|
329
|
+
|
|
330
|
+
await downloadPlaylistAsZip(mockPlaylist, options);
|
|
331
|
+
|
|
332
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("should generate M3U when option is enabled", async () => {
|
|
336
|
+
const options = { generateM3U: true };
|
|
337
|
+
|
|
338
|
+
await downloadPlaylistAsZip(mockPlaylist, options);
|
|
339
|
+
|
|
340
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("should not include M3U when generateM3U option is false", async () => {
|
|
344
|
+
const options: PlaylistDownloadOptions = { generateM3U: false };
|
|
345
|
+
|
|
346
|
+
await downloadPlaylistAsZip(mockPlaylist, options);
|
|
347
|
+
|
|
348
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("should include HTML when includeHTML option is true", async () => {
|
|
352
|
+
const options = { includeHTML: true };
|
|
353
|
+
|
|
354
|
+
await downloadPlaylistAsZip(mockPlaylist, options);
|
|
355
|
+
|
|
356
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("should not include HTML when includeHTML option is false", async () => {
|
|
360
|
+
const options: PlaylistDownloadOptions = { includeHTML: false };
|
|
361
|
+
|
|
362
|
+
await downloadPlaylistAsZip(mockPlaylist, options);
|
|
363
|
+
|
|
364
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("M3U Generation (via ZIP download)", () => {
|
|
369
|
+
it("should include M3U content when generateM3U option is true", async () => {
|
|
370
|
+
const options = { generateM3U: true };
|
|
371
|
+
|
|
372
|
+
await downloadPlaylistAsZip(mockPlaylist, options);
|
|
373
|
+
|
|
374
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("Filename Safety (via ZIP download)", () => {
|
|
379
|
+
it("should create safe filenames for songs with special characters", async () => {
|
|
380
|
+
const playlistWithSpecialChars = {
|
|
381
|
+
...mockPlaylist,
|
|
382
|
+
title: 'Playlist/With\\Special:Chars|<>*?"',
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const songsWithSpecialChars = [
|
|
386
|
+
{
|
|
387
|
+
...mockSongs[0]!,
|
|
388
|
+
originalFilename: 'special!@#.mp3',
|
|
389
|
+
},
|
|
390
|
+
];
|
|
391
|
+
const { getSongsForPlaylist } = await import("./playlistDocService.js");
|
|
392
|
+
vi.mocked(getSongsForPlaylist).mockResolvedValue(
|
|
393
|
+
songsWithSpecialChars as Song[]
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
await downloadPlaylistAsZip(playlistWithSpecialChars);
|
|
397
|
+
|
|
398
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("File Extension Handling (via ZIP download)", () => {
|
|
403
|
+
it("should handle different audio file types", async () => {
|
|
404
|
+
const songsWithDifferentTypes = [
|
|
405
|
+
{ ...mockSongs[0]!, mimeType: "audio/mp3", originalFilename: "song1.mp3" },
|
|
406
|
+
{ ...mockSongs[1]!, mimeType: "audio/wav", originalFilename: "song2.wav" },
|
|
407
|
+
];
|
|
408
|
+
const { getSongsForPlaylist } = await import("./playlistDocService.js");
|
|
409
|
+
vi.mocked(getSongsForPlaylist).mockResolvedValue(
|
|
410
|
+
songsWithDifferentTypes as Song[]
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
await downloadPlaylistAsZip(mockPlaylist);
|
|
414
|
+
|
|
415
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("MIME Type Handling", () => {
|
|
420
|
+
it("should preserve MIME types during download", async () => {
|
|
421
|
+
const songsWithMimeTypes = [
|
|
422
|
+
{ ...mockSongs[0]!, mimeType: "audio/wav", originalFilename: "song1.wav" },
|
|
423
|
+
{ ...mockSongs[1]!, mimeType: "audio/flac", originalFilename: "song2.flac" },
|
|
424
|
+
];
|
|
425
|
+
const { getSongsForPlaylist } = await import("./playlistDocService.js");
|
|
426
|
+
vi.mocked(getSongsForPlaylist).mockResolvedValue(
|
|
427
|
+
songsWithMimeTypes as Song[]
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
await downloadPlaylistAsZip(mockPlaylist);
|
|
431
|
+
|
|
432
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe("Base64 Handling (internal)", () => {
|
|
437
|
+
it("should handle playlist with inline imageData fallback", async () => {
|
|
438
|
+
const playlistWithImage = {
|
|
439
|
+
...mockPlaylist,
|
|
440
|
+
_primaryImageSha: undefined,
|
|
441
|
+
imageData: new ArrayBuffer(100),
|
|
442
|
+
imageType: "image/jpeg",
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
await downloadPlaylistAsZip(playlistWithImage);
|
|
446
|
+
|
|
447
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
describe("Filename Sanitization (internal)", () => {
|
|
452
|
+
it("should sanitize problematic filenames in downloads", async () => {
|
|
453
|
+
const problemPlaylist = {
|
|
454
|
+
...mockPlaylist,
|
|
455
|
+
title: "CON",
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
await downloadPlaylistAsZip(problemPlaylist);
|
|
459
|
+
|
|
460
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe("parsePlaylistZip", () => {
|
|
465
|
+
let mockZipFile: any;
|
|
466
|
+
|
|
467
|
+
beforeEach(() => {
|
|
468
|
+
mockZipFile = {
|
|
469
|
+
files: {},
|
|
470
|
+
file: vi.fn((pattern) => {
|
|
471
|
+
if (typeof pattern === "string") {
|
|
472
|
+
if (pattern === "data/playlist.json") {
|
|
473
|
+
return {
|
|
474
|
+
async: vi.fn().mockResolvedValue(
|
|
475
|
+
JSON.stringify({
|
|
476
|
+
playlist: mockPlaylist,
|
|
477
|
+
songs: mockSongs,
|
|
478
|
+
})
|
|
479
|
+
),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (pattern === "playlist-info.json") {
|
|
483
|
+
return {
|
|
484
|
+
async: vi.fn().mockResolvedValue(JSON.stringify(mockPlaylist)),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return null;
|
|
488
|
+
} else if (pattern instanceof RegExp) {
|
|
489
|
+
if (pattern.test("folder/data/playlist.json")) {
|
|
490
|
+
return [
|
|
491
|
+
{
|
|
492
|
+
async: vi.fn().mockResolvedValue(
|
|
493
|
+
JSON.stringify({
|
|
494
|
+
playlist: mockPlaylist,
|
|
495
|
+
songs: mockSongs,
|
|
496
|
+
})
|
|
497
|
+
),
|
|
498
|
+
},
|
|
499
|
+
];
|
|
500
|
+
}
|
|
501
|
+
if (
|
|
502
|
+
pattern.test("folder/data/song1.mp3") ||
|
|
503
|
+
pattern.test("data/song1.mp3") ||
|
|
504
|
+
pattern.test("song1.mp3")
|
|
505
|
+
) {
|
|
506
|
+
return [
|
|
507
|
+
{
|
|
508
|
+
name: "data/song1.mp3",
|
|
509
|
+
async: vi.fn().mockResolvedValue(new ArrayBuffer(1000)),
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
name: "data/song2.mp3",
|
|
513
|
+
async: vi.fn().mockResolvedValue(new ArrayBuffer(1500)),
|
|
514
|
+
},
|
|
515
|
+
];
|
|
516
|
+
}
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
return [];
|
|
520
|
+
}),
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
vi.mocked(JSZip).mockImplementation(
|
|
524
|
+
() =>
|
|
525
|
+
({
|
|
526
|
+
loadAsync: vi.fn().mockResolvedValue(mockZipFile),
|
|
527
|
+
}) as any
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("should parse playlist zip file correctly", async () => {
|
|
532
|
+
const zipFile = new File(["mock zip content"], "playlist.zip", {
|
|
533
|
+
type: "application/zip",
|
|
534
|
+
});
|
|
535
|
+
const result = await parsePlaylistZip(zipFile);
|
|
536
|
+
|
|
537
|
+
expect(result).toHaveProperty("playlist");
|
|
538
|
+
expect(result).toHaveProperty("songs");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("should handle zip files without playlist.json", async () => {
|
|
542
|
+
const mockEmptyZipFile = {
|
|
543
|
+
files: {},
|
|
544
|
+
file: vi.fn(() => []),
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
vi.mocked(JSZip).mockImplementation(
|
|
548
|
+
() =>
|
|
549
|
+
({
|
|
550
|
+
loadAsync: vi.fn().mockResolvedValue(mockEmptyZipFile),
|
|
551
|
+
}) as any
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
const zipFile = new File(["mock zip content"], "playlist.zip", {
|
|
555
|
+
type: "application/zip",
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
await expect(parsePlaylistZip(zipFile)).rejects.toThrow();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("should handle corrupted zip files", async () => {
|
|
562
|
+
vi.mocked(JSZip).mockImplementation(
|
|
563
|
+
() =>
|
|
564
|
+
({
|
|
565
|
+
loadAsync: vi.fn().mockRejectedValue(new Error("Corrupted ZIP")),
|
|
566
|
+
}) as any
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const zipFile = new File(["corrupted content"], "corrupted.zip", {
|
|
570
|
+
type: "application/zip",
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
await expect(parsePlaylistZip(zipFile)).rejects.toThrow("Corrupted ZIP");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("should handle invalid json in playlist.json", async () => {
|
|
577
|
+
const mockInvalidZipFile = {
|
|
578
|
+
files: {},
|
|
579
|
+
file: vi.fn((pattern) => {
|
|
580
|
+
if (pattern instanceof RegExp && pattern.test("playlist.json")) {
|
|
581
|
+
return [
|
|
582
|
+
{
|
|
583
|
+
async: vi.fn().mockResolvedValue("invalid json"),
|
|
584
|
+
},
|
|
585
|
+
];
|
|
586
|
+
}
|
|
587
|
+
return [];
|
|
588
|
+
}),
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
vi.mocked(JSZip).mockImplementation(
|
|
592
|
+
() =>
|
|
593
|
+
({
|
|
594
|
+
loadAsync: vi.fn().mockResolvedValue(mockInvalidZipFile),
|
|
595
|
+
}) as any
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
const zipFile = new File(["mock zip content"], "playlist.zip", {
|
|
599
|
+
type: "application/zip",
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
await expect(parsePlaylistZip(zipFile)).rejects.toThrow();
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
describe("Standalone HTML Generation", () => {
|
|
607
|
+
it("should include HTML when includeHTML option is true", async () => {
|
|
608
|
+
const options = { includeHTML: true };
|
|
609
|
+
|
|
610
|
+
await downloadPlaylistAsZip(mockPlaylist, options);
|
|
611
|
+
|
|
612
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("should not include HTML when includeHTML option is false", async () => {
|
|
616
|
+
const options: PlaylistDownloadOptions = { includeHTML: false };
|
|
617
|
+
|
|
618
|
+
await downloadPlaylistAsZip(mockPlaylist, options);
|
|
619
|
+
|
|
620
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
describe("Integration Tests", () => {
|
|
625
|
+
it("should complete full download workflow", async () => {
|
|
626
|
+
const { getSongsForPlaylist, updatePlaylist } = await import(
|
|
627
|
+
"./playlistDocService.js"
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
const options = {
|
|
631
|
+
generateM3U: true,
|
|
632
|
+
includeImages: true,
|
|
633
|
+
includeHTML: true,
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
await downloadPlaylistAsZip(mockPlaylist, options);
|
|
637
|
+
|
|
638
|
+
expect(vi.mocked(getSongsForPlaylist)).toHaveBeenCalledWith(
|
|
639
|
+
mockPlaylist.id
|
|
640
|
+
);
|
|
641
|
+
expect(vi.mocked(updatePlaylist)).toHaveBeenCalled();
|
|
642
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("should handle mixed scenarios with partial data", async () => {
|
|
646
|
+
const mixedSongs = [
|
|
647
|
+
{ ...mockSongs[0]!, sha: "sha-a" },
|
|
648
|
+
{ ...mockSongs[1]!, sha: undefined },
|
|
649
|
+
];
|
|
650
|
+
const { getSongsForPlaylist } = await import("./playlistDocService.js");
|
|
651
|
+
vi.mocked(getSongsForPlaylist).mockResolvedValue(mixedSongs as Song[]);
|
|
652
|
+
|
|
653
|
+
await downloadPlaylistAsZip(mockPlaylist);
|
|
654
|
+
|
|
655
|
+
expect(vi.mocked(JSZip)).toHaveBeenCalled();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("should maintain data integrity throughout workflow", async () => {
|
|
659
|
+
const { getSongsForPlaylist, updatePlaylist } = await import(
|
|
660
|
+
"./playlistDocService.js"
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
await downloadPlaylistAsZip(mockPlaylist);
|
|
664
|
+
|
|
665
|
+
expect(vi.mocked(updatePlaylist)).toHaveBeenCalledWith(mockPlaylist.id, {
|
|
666
|
+
rev: mockPlaylist.rev! + 1,
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
expect(vi.mocked(getSongsForPlaylist)).toHaveBeenCalledWith(
|
|
670
|
+
mockPlaylist.id
|
|
671
|
+
);
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
});
|