@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,448 @@
|
|
|
1
|
+
// tests for the doc-backed playlist/song crud service.
|
|
2
|
+
//
|
|
3
|
+
// uses the real automerge repo (fake-indexeddb storage) with mocked
|
|
4
|
+
// IrohNetworkAdapter, p2pService, and blob store. covers the interfaces
|
|
5
|
+
// the ui depends on, including regressions found in live debugging:
|
|
6
|
+
// - automerge RangeError from re-inserting doc-derived objects
|
|
7
|
+
// - solid proxy objects crossing the persistence boundary
|
|
8
|
+
// - getSongById registry miss after a page reload
|
|
9
|
+
// - image hydration from the blob store (imageFilePath/imageType)
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
12
|
+
import "fake-indexeddb/auto";
|
|
13
|
+
import { IDBFactory } from "fake-indexeddb";
|
|
14
|
+
|
|
15
|
+
// --- mocks (hoisted before module imports) ---
|
|
16
|
+
|
|
17
|
+
vi.mock("@freqhole/api-client/automerge", async () => {
|
|
18
|
+
const { NetworkAdapter } = await vi.importActual<
|
|
19
|
+
typeof import("@automerge/automerge-repo")
|
|
20
|
+
>("@automerge/automerge-repo");
|
|
21
|
+
|
|
22
|
+
class MockIrohNetworkAdapter extends NetworkAdapter {
|
|
23
|
+
isReady() {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
async whenReady() {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
connect() {}
|
|
30
|
+
disconnect() {}
|
|
31
|
+
send() {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { IrohNetworkAdapter: MockIrohNetworkAdapter };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
vi.mock("./p2pService.js", () => ({
|
|
38
|
+
getAdapterOptions: vi.fn(() => ({
|
|
39
|
+
getNode: async () => {
|
|
40
|
+
throw new Error("not available in tests");
|
|
41
|
+
},
|
|
42
|
+
getIdentity: async () => null,
|
|
43
|
+
})),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// in-memory blob store mock - storeBlob returns deterministic ids,
|
|
47
|
+
// getBlobObjectURL/getBlobMetadata resolve for stored ids only
|
|
48
|
+
const { blobStore } = vi.hoisted(() => ({
|
|
49
|
+
blobStore: new Map<string, { mimeType: string; size: number }>(),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
vi.mock("@freqhole/api-client/storage", () => ({
|
|
53
|
+
storeBlob: vi.fn(async (blob: Blob, mimeType: string) => {
|
|
54
|
+
const id = `sha-${blobStore.size + 1}-${blob.size}`;
|
|
55
|
+
blobStore.set(id, { mimeType, size: blob.size });
|
|
56
|
+
return id;
|
|
57
|
+
}),
|
|
58
|
+
getBlob: vi.fn(async (id: string) =>
|
|
59
|
+
blobStore.has(id) ? new Blob(["x"]) : null
|
|
60
|
+
),
|
|
61
|
+
getBlobObjectURL: vi.fn(async (id: string) =>
|
|
62
|
+
blobStore.has(id) ? `blob:mock-${id}` : null
|
|
63
|
+
),
|
|
64
|
+
getBlobMetadata: vi.fn(async (id: string) => {
|
|
65
|
+
const rec = blobStore.get(id);
|
|
66
|
+
if (!rec) return null;
|
|
67
|
+
return {
|
|
68
|
+
blob_id: id,
|
|
69
|
+
storage_type: "opfs",
|
|
70
|
+
storage_path: id,
|
|
71
|
+
mime_type: rec.mimeType,
|
|
72
|
+
file_size: rec.size,
|
|
73
|
+
created_at: 0,
|
|
74
|
+
};
|
|
75
|
+
}),
|
|
76
|
+
deleteBlob: vi.fn(async (id: string) => {
|
|
77
|
+
blobStore.delete(id);
|
|
78
|
+
}),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
import {
|
|
82
|
+
createPlaylist,
|
|
83
|
+
updatePlaylist,
|
|
84
|
+
deletePlaylist,
|
|
85
|
+
addSongToPlaylist,
|
|
86
|
+
updateSongInDoc,
|
|
87
|
+
deleteSong,
|
|
88
|
+
reorderSongsInDoc,
|
|
89
|
+
getSongsForPlaylist,
|
|
90
|
+
getSongById,
|
|
91
|
+
setPlaylistCoverImage,
|
|
92
|
+
clearPlaylistCoverImage,
|
|
93
|
+
setSongCoverImage,
|
|
94
|
+
docToPlaylist,
|
|
95
|
+
docToPlaylistAsync,
|
|
96
|
+
_clearSongRegistryForTests,
|
|
97
|
+
} from "./playlistDocService.js";
|
|
98
|
+
import { _resetRepoForTests, findPlaylistDoc } from "./automergeRepo.js";
|
|
99
|
+
import { resetDBCache } from "./indexedDBService.js";
|
|
100
|
+
import { getAllDocIndexEntries, getDocIndexEntry } from "./docIndexService.js";
|
|
101
|
+
import { parsePlaylistDoc } from "@freqhole/api-client/playlistz";
|
|
102
|
+
import type { AutomergeUrl } from "@automerge/automerge-repo";
|
|
103
|
+
|
|
104
|
+
function makeAudioFile(name = "track.mp3", content = "fake audio"): File {
|
|
105
|
+
const file = new File([content], name, { type: "audio/mpeg" });
|
|
106
|
+
// jsdom's File lacks arrayBuffer()
|
|
107
|
+
if (typeof file.arrayBuffer !== "function") {
|
|
108
|
+
Object.defineProperty(file, "arrayBuffer", {
|
|
109
|
+
value: async () => new TextEncoder().encode(content).buffer,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return file;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe("playlistDocService", () => {
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
globalThis.indexedDB = new IDBFactory();
|
|
118
|
+
resetDBCache();
|
|
119
|
+
_resetRepoForTests();
|
|
120
|
+
_clearSongRegistryForTests();
|
|
121
|
+
blobStore.clear();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("createPlaylist", () => {
|
|
125
|
+
it("returns a playlist view with an automerge docId", async () => {
|
|
126
|
+
const playlist = await createPlaylist({ title: "test" });
|
|
127
|
+
expect(playlist.id).toMatch(/^automerge:/);
|
|
128
|
+
expect(playlist.title).toBe("test");
|
|
129
|
+
expect(playlist.songIds).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("adds a docIndex entry", async () => {
|
|
133
|
+
const playlist = await createPlaylist({ title: "indexed" });
|
|
134
|
+
const entries = await getAllDocIndexEntries();
|
|
135
|
+
expect(entries).toHaveLength(1);
|
|
136
|
+
expect(entries[0]!.docId).toBe(playlist.id);
|
|
137
|
+
expect(entries[0]!.title).toBe("indexed");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("updatePlaylist", () => {
|
|
142
|
+
it("persists title and description to the doc", async () => {
|
|
143
|
+
const playlist = await createPlaylist({ title: "before" });
|
|
144
|
+
await updatePlaylist(playlist.id, {
|
|
145
|
+
title: "after",
|
|
146
|
+
description: "desc",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const handle = await findPlaylistDoc(playlist.id as AutomergeUrl);
|
|
150
|
+
const doc = parsePlaylistDoc(handle.doc());
|
|
151
|
+
expect(doc.title).toBe("after");
|
|
152
|
+
expect(doc.description).toBe("desc");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("updates the docIndex title", async () => {
|
|
156
|
+
const playlist = await createPlaylist({ title: "before" });
|
|
157
|
+
await updatePlaylist(playlist.id, { title: "after" });
|
|
158
|
+
const entry = await getDocIndexEntry(playlist.id);
|
|
159
|
+
expect(entry?.title).toBe("after");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("persists display filter fields", async () => {
|
|
163
|
+
const playlist = await createPlaylist({ title: "filters" });
|
|
164
|
+
await updatePlaylist(playlist.id, {
|
|
165
|
+
bgFilterEnabled: false,
|
|
166
|
+
bgFilterBlur: 5,
|
|
167
|
+
coverFilterBlur: 2,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const handle = await findPlaylistDoc(playlist.id as AutomergeUrl);
|
|
171
|
+
const doc = parsePlaylistDoc(handle.doc());
|
|
172
|
+
expect(doc.bgFilterEnabled).toBe(false);
|
|
173
|
+
expect(doc.bgFilterBlur).toBe(5);
|
|
174
|
+
expect(doc.coverFilterBlur).toBe(2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("accepts proxy-wrapped fields (solid store objects)", async () => {
|
|
178
|
+
const playlist = await createPlaylist({ title: "proxy" });
|
|
179
|
+
// simulate a solid store proxy crossing the boundary
|
|
180
|
+
const proxied = new Proxy(
|
|
181
|
+
{ title: "from proxy", description: "proxied desc" },
|
|
182
|
+
{}
|
|
183
|
+
);
|
|
184
|
+
await expect(updatePlaylist(playlist.id, proxied)).resolves.not.toThrow();
|
|
185
|
+
|
|
186
|
+
const handle = await findPlaylistDoc(playlist.id as AutomergeUrl);
|
|
187
|
+
const doc = parsePlaylistDoc(handle.doc());
|
|
188
|
+
expect(doc.title).toBe("from proxy");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("deletePlaylist", () => {
|
|
193
|
+
it("removes the docIndex entry", async () => {
|
|
194
|
+
const playlist = await createPlaylist({ title: "doomed" });
|
|
195
|
+
await deletePlaylist(playlist.id);
|
|
196
|
+
const entries = await getAllDocIndexEntries();
|
|
197
|
+
expect(entries).toHaveLength(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("clears the playlist's songs from the registry", async () => {
|
|
201
|
+
const playlist = await createPlaylist({ title: "doomed" });
|
|
202
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile());
|
|
203
|
+
await deletePlaylist(playlist.id);
|
|
204
|
+
_clearSongRegistryForTests();
|
|
205
|
+
// docIndex entry is gone, so the fallback scan finds nothing
|
|
206
|
+
expect(await getSongById(song.id)).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("addSongToPlaylist", () => {
|
|
211
|
+
it("adds a song entry to the doc and returns a Song view", async () => {
|
|
212
|
+
const playlist = await createPlaylist({ title: "with songs" });
|
|
213
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile(), {
|
|
214
|
+
title: "my song",
|
|
215
|
+
artist: "artist",
|
|
216
|
+
album: "album",
|
|
217
|
+
duration: 120,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(song.title).toBe("my song");
|
|
221
|
+
expect(song.artist).toBe("artist");
|
|
222
|
+
expect(song.playlistId).toBe(playlist.id);
|
|
223
|
+
expect(song.sha256).toBeTruthy();
|
|
224
|
+
|
|
225
|
+
const songs = await getSongsForPlaylist(playlist.id);
|
|
226
|
+
expect(songs).toHaveLength(1);
|
|
227
|
+
expect(songs[0]!.id).toBe(song.id);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("stores audio bytes in the blob store keyed by sha", async () => {
|
|
231
|
+
const playlist = await createPlaylist({ title: "blobs" });
|
|
232
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile());
|
|
233
|
+
expect(blobStore.has(song.sha256!)).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("stores cover art and carries an image ref on the song", async () => {
|
|
237
|
+
const playlist = await createPlaylist({ title: "art" });
|
|
238
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile(), {
|
|
239
|
+
imageData: new ArrayBuffer(8),
|
|
240
|
+
imageType: "image/png",
|
|
241
|
+
});
|
|
242
|
+
expect(song.images).toHaveLength(1);
|
|
243
|
+
expect(song.images![0]!.isPrimary).toBe(true);
|
|
244
|
+
expect(blobStore.has(song.images![0]!.blobId)).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("updateSongInDoc", () => {
|
|
249
|
+
it("updates metadata fields", async () => {
|
|
250
|
+
const playlist = await createPlaylist({ title: "edit" });
|
|
251
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile(), {
|
|
252
|
+
title: "orig",
|
|
253
|
+
album: "orig album",
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await updateSongInDoc(playlist.id, song.id, { album: "new album" });
|
|
257
|
+
|
|
258
|
+
const songs = await getSongsForPlaylist(playlist.id);
|
|
259
|
+
expect(songs[0]!.album).toBe("new album");
|
|
260
|
+
expect(songs[0]!.title).toBe("orig");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("does not throw RangeError when passed a full Song view object", async () => {
|
|
264
|
+
// regression: SongEditPanel passes the whole (doc-derived) song back.
|
|
265
|
+
// the old implementation spread the automerge proxy and re-inserted it,
|
|
266
|
+
// which made automerge throw "Cannot create a reference to an existing
|
|
267
|
+
// document object"
|
|
268
|
+
const playlist = await createPlaylist({ title: "regression" });
|
|
269
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile(), {
|
|
270
|
+
imageData: new ArrayBuffer(8),
|
|
271
|
+
imageType: "image/png",
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const fetched = await getSongById(song.id);
|
|
275
|
+
expect(fetched).not.toBeNull();
|
|
276
|
+
|
|
277
|
+
await expect(
|
|
278
|
+
updateSongInDoc(playlist.id, song.id, {
|
|
279
|
+
...fetched!,
|
|
280
|
+
album: "edited album",
|
|
281
|
+
})
|
|
282
|
+
).resolves.not.toThrow();
|
|
283
|
+
|
|
284
|
+
const songs = await getSongsForPlaylist(playlist.id);
|
|
285
|
+
expect(songs[0]!.album).toBe("edited album");
|
|
286
|
+
// image refs survive a metadata-only edit
|
|
287
|
+
expect(songs[0]!.images).toHaveLength(1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("replaces images when new image data is provided", async () => {
|
|
291
|
+
const playlist = await createPlaylist({ title: "img replace" });
|
|
292
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile(), {
|
|
293
|
+
imageData: new ArrayBuffer(8),
|
|
294
|
+
imageType: "image/png",
|
|
295
|
+
});
|
|
296
|
+
const firstBlobId = song.images![0]!.blobId;
|
|
297
|
+
|
|
298
|
+
await updateSongInDoc(playlist.id, song.id, {
|
|
299
|
+
imageData: new ArrayBuffer(16),
|
|
300
|
+
imageType: "image/jpeg",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const songs = await getSongsForPlaylist(playlist.id);
|
|
304
|
+
expect(songs[0]!.images).toHaveLength(1);
|
|
305
|
+
expect(songs[0]!.images![0]!.blobId).not.toBe(firstBlobId);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("deleteSong", () => {
|
|
310
|
+
it("removes the song from the doc and registry", async () => {
|
|
311
|
+
const playlist = await createPlaylist({ title: "removal" });
|
|
312
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile());
|
|
313
|
+
|
|
314
|
+
await deleteSong(playlist.id, song.id);
|
|
315
|
+
|
|
316
|
+
expect(await getSongsForPlaylist(playlist.id)).toHaveLength(0);
|
|
317
|
+
expect(await getSongById(song.id)).toBeNull();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("reorderSongsInDoc", () => {
|
|
322
|
+
it("moves a song to a new position", async () => {
|
|
323
|
+
const playlist = await createPlaylist({ title: "order" });
|
|
324
|
+
const a = await addSongToPlaylist(playlist.id, makeAudioFile("a.mp3", "aaa"));
|
|
325
|
+
const b = await addSongToPlaylist(playlist.id, makeAudioFile("b.mp3", "bbb"));
|
|
326
|
+
const c = await addSongToPlaylist(playlist.id, makeAudioFile("c.mp3", "ccc"));
|
|
327
|
+
|
|
328
|
+
await reorderSongsInDoc(playlist.id, 0, 2);
|
|
329
|
+
|
|
330
|
+
const songs = await getSongsForPlaylist(playlist.id);
|
|
331
|
+
expect(songs.map((s) => s.id)).toEqual([b.id, c.id, a.id]);
|
|
332
|
+
expect(songs.map((s) => s.position)).toEqual([0, 1, 2]);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("getSongById", () => {
|
|
337
|
+
it("returns a registered song", async () => {
|
|
338
|
+
const playlist = await createPlaylist({ title: "lookup" });
|
|
339
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile(), {
|
|
340
|
+
title: "findable",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const found = await getSongById(song.id);
|
|
344
|
+
expect(found?.title).toBe("findable");
|
|
345
|
+
expect(found?.playlistId).toBe(playlist.id);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("rebuilds the registry from the docIndex on a miss (reload scenario)", async () => {
|
|
349
|
+
// regression: after a page reload the in-memory registry is empty,
|
|
350
|
+
// and song rows rendered "song not found" for every song
|
|
351
|
+
const playlist = await createPlaylist({ title: "reload" });
|
|
352
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile(), {
|
|
353
|
+
title: "survives reload",
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
_clearSongRegistryForTests();
|
|
357
|
+
|
|
358
|
+
const found = await getSongById(song.id);
|
|
359
|
+
expect(found?.title).toBe("survives reload");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("returns null for unknown ids", async () => {
|
|
363
|
+
await createPlaylist({ title: "empty" });
|
|
364
|
+
expect(await getSongById("nope")).toBeNull();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("image hydration", () => {
|
|
369
|
+
it("getSongsForPlaylist hydrates imageFilePath and imageType", async () => {
|
|
370
|
+
const playlist = await createPlaylist({ title: "hydrate" });
|
|
371
|
+
await addSongToPlaylist(playlist.id, makeAudioFile(), {
|
|
372
|
+
imageData: new ArrayBuffer(8),
|
|
373
|
+
imageType: "image/png",
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const songs = await getSongsForPlaylist(playlist.id);
|
|
377
|
+
expect(songs[0]!.imageFilePath).toMatch(/^blob:mock-/);
|
|
378
|
+
expect(songs[0]!.imageType).toBe("image/png");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("getSongById hydrates the image after a registry rebuild", async () => {
|
|
382
|
+
const playlist = await createPlaylist({ title: "hydrate reload" });
|
|
383
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile(), {
|
|
384
|
+
imageData: new ArrayBuffer(8),
|
|
385
|
+
imageType: "image/png",
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
_clearSongRegistryForTests();
|
|
389
|
+
|
|
390
|
+
const found = await getSongById(song.id);
|
|
391
|
+
expect(found?.imageFilePath).toMatch(/^blob:mock-/);
|
|
392
|
+
expect(found?.imageType).toBe("image/png");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("songs without images are left unhydrated", async () => {
|
|
396
|
+
const playlist = await createPlaylist({ title: "no image" });
|
|
397
|
+
await addSongToPlaylist(playlist.id, makeAudioFile());
|
|
398
|
+
|
|
399
|
+
const songs = await getSongsForPlaylist(playlist.id);
|
|
400
|
+
expect(songs[0]!.imageFilePath).toBeUndefined();
|
|
401
|
+
expect(songs[0]!.imageType).toBeUndefined();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("docToPlaylistAsync hydrates the playlist cover from the blob store", async () => {
|
|
405
|
+
const playlist = await createPlaylist({ title: "cover" });
|
|
406
|
+
await setPlaylistCoverImage(
|
|
407
|
+
playlist.id,
|
|
408
|
+
new ArrayBuffer(8),
|
|
409
|
+
"image/webp"
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const handle = await findPlaylistDoc(playlist.id as AutomergeUrl);
|
|
413
|
+
const doc = parsePlaylistDoc(handle.doc());
|
|
414
|
+
const view = await docToPlaylistAsync(playlist.id, doc);
|
|
415
|
+
|
|
416
|
+
expect(view._primaryImageSha).toBeTruthy();
|
|
417
|
+
expect(view.imageFilePath).toMatch(/^blob:mock-/);
|
|
418
|
+
expect(view.imageType).toBe("image/webp");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("clearPlaylistCoverImage removes the cover refs", async () => {
|
|
422
|
+
const playlist = await createPlaylist({ title: "uncover" });
|
|
423
|
+
await setPlaylistCoverImage(playlist.id, new ArrayBuffer(8), "image/png");
|
|
424
|
+
await clearPlaylistCoverImage(playlist.id);
|
|
425
|
+
|
|
426
|
+
const handle = await findPlaylistDoc(playlist.id as AutomergeUrl);
|
|
427
|
+
const doc = parsePlaylistDoc(handle.doc());
|
|
428
|
+
expect(doc.images).toHaveLength(0);
|
|
429
|
+
expect(docToPlaylist(playlist.id, doc)._primaryImageSha).toBeUndefined();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("setSongCoverImage attaches a primary image to the song", async () => {
|
|
433
|
+
const playlist = await createPlaylist({ title: "song cover" });
|
|
434
|
+
const song = await addSongToPlaylist(playlist.id, makeAudioFile());
|
|
435
|
+
|
|
436
|
+
await setSongCoverImage(
|
|
437
|
+
playlist.id,
|
|
438
|
+
song.id,
|
|
439
|
+
new ArrayBuffer(8),
|
|
440
|
+
"image/png"
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const songs = await getSongsForPlaylist(playlist.id);
|
|
444
|
+
expect(songs[0]!.images!.length).toBeGreaterThan(0);
|
|
445
|
+
expect(songs[0]!.imageFilePath).toMatch(/^blob:mock-/);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
});
|