@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,702 @@
|
|
|
1
|
+
// p2p blob transfer for playlistz (phase 6).
|
|
2
|
+
//
|
|
3
|
+
// docs carry sha256 hashes; bytes live in the shared blob store. when a
|
|
4
|
+
// blob is missing locally, this service fetches it from a doc's peers
|
|
5
|
+
// using iroh-blobs verified streaming:
|
|
6
|
+
//
|
|
7
|
+
// requester owner
|
|
8
|
+
// --------- -----
|
|
9
|
+
// open_bi(freqhole-playlistz/1)
|
|
10
|
+
// blob_request { sha256 } ----> getBlob(sha256) from blob store
|
|
11
|
+
// import_blob into iroh-blobs store
|
|
12
|
+
// blob_ready { blake3, size } <----
|
|
13
|
+
// download_verified_streaming(blake3) [iroh-blobs ALPN, rust-side]
|
|
14
|
+
// assemble chunks -> storeBlob
|
|
15
|
+
//
|
|
16
|
+
// the serving side keeps an import cache with a release timer so repeat
|
|
17
|
+
// requests skip the bao recomputation and memory is bounded.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
getBlob,
|
|
21
|
+
getBlobMetadata,
|
|
22
|
+
storeBlob,
|
|
23
|
+
} from "@freqhole/api-client/storage";
|
|
24
|
+
import { createSignal } from "solid-js";
|
|
25
|
+
import {
|
|
26
|
+
PLAYLISTZ_ALPN,
|
|
27
|
+
sendMessage,
|
|
28
|
+
readMessage,
|
|
29
|
+
type BiStreamLike,
|
|
30
|
+
} from "@freqhole/api-client/playlistz";
|
|
31
|
+
import type { AutomergeUrl } from "@automerge/automerge-repo";
|
|
32
|
+
import { getNode } from "./p2pService.js";
|
|
33
|
+
import { getIrohAdapter, findPlaylistDoc } from "./automergeRepo.js";
|
|
34
|
+
import { getIdentity } from "./p2pService.js";
|
|
35
|
+
import { getSongsForPlaylist } from "./playlistDocService.js";
|
|
36
|
+
import type { Playlist, Song } from "../types/playlist.js";
|
|
37
|
+
|
|
38
|
+
// midden node surface used here, beyond the stream interface declared in
|
|
39
|
+
// freqhole-api-client/automerge. structural cast - midden provides these.
|
|
40
|
+
interface BlobCapableNode {
|
|
41
|
+
node_id(): string;
|
|
42
|
+
open_bi(peer_addr: string, alpn: string): Promise<unknown>;
|
|
43
|
+
import_blob(data: Uint8Array): Promise<string>;
|
|
44
|
+
release_blob(blake3_hash: string): void;
|
|
45
|
+
download_verified_streaming(
|
|
46
|
+
peer_addr: string,
|
|
47
|
+
blake3_hash: string,
|
|
48
|
+
total_size: number,
|
|
49
|
+
on_chunk: (chunk: Uint8Array, offset: number) => void,
|
|
50
|
+
on_progress: (fraction: number) => void
|
|
51
|
+
): Promise<number>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getBlobNode(): BlobCapableNode | null {
|
|
55
|
+
return getNode() as unknown as BlobCapableNode | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- serving side ---
|
|
59
|
+
|
|
60
|
+
// sha256 -> blake3 for blobs currently imported into the iroh-blobs store
|
|
61
|
+
const servedBlobs = new Map<
|
|
62
|
+
string,
|
|
63
|
+
{ blake3: string; releaseTimer: ReturnType<typeof setTimeout> }
|
|
64
|
+
>();
|
|
65
|
+
|
|
66
|
+
// how long an imported blob stays available after the last request
|
|
67
|
+
const RELEASE_AFTER_MS = 10 * 60 * 1000;
|
|
68
|
+
|
|
69
|
+
// count of in-progress outbound serve requests (we are serving a blob to a peer)
|
|
70
|
+
let activeServes = 0;
|
|
71
|
+
|
|
72
|
+
function scheduleRelease(sha256: string, blake3: string): void {
|
|
73
|
+
const existing = servedBlobs.get(sha256);
|
|
74
|
+
if (existing) {
|
|
75
|
+
clearTimeout(existing.releaseTimer);
|
|
76
|
+
}
|
|
77
|
+
const releaseTimer = setTimeout(() => {
|
|
78
|
+
servedBlobs.delete(sha256);
|
|
79
|
+
try {
|
|
80
|
+
getBlobNode()?.release_blob(blake3);
|
|
81
|
+
} catch {
|
|
82
|
+
// node may be gone
|
|
83
|
+
}
|
|
84
|
+
}, RELEASE_AFTER_MS);
|
|
85
|
+
servedBlobs.set(sha256, { blake3, releaseTimer });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* answer a blob_request on an open protocol stream: import the local
|
|
90
|
+
* blob into the iroh-blobs store and reply with its blake3 + size.
|
|
91
|
+
* called from the sharing service's stream handler.
|
|
92
|
+
*/
|
|
93
|
+
export async function serveBlobRequest(
|
|
94
|
+
stream: BiStreamLike,
|
|
95
|
+
sha256: string
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
activeServes++;
|
|
98
|
+
notifyTransferListeners();
|
|
99
|
+
try {
|
|
100
|
+
await _serveBlobRequest(stream, sha256);
|
|
101
|
+
} finally {
|
|
102
|
+
activeServes--;
|
|
103
|
+
notifyTransferListeners();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function _serveBlobRequest(
|
|
108
|
+
stream: BiStreamLike,
|
|
109
|
+
sha256: string
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const node = getBlobNode();
|
|
112
|
+
if (!node) {
|
|
113
|
+
await sendMessage(stream, {
|
|
114
|
+
v: 1,
|
|
115
|
+
type: "error",
|
|
116
|
+
code: "no_node",
|
|
117
|
+
message: "p2p node is not running",
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const blob = await getBlob(sha256);
|
|
123
|
+
if (!blob) {
|
|
124
|
+
await sendMessage(stream, {
|
|
125
|
+
v: 1,
|
|
126
|
+
type: "error",
|
|
127
|
+
code: "blob_not_found",
|
|
128
|
+
message: `no blob with sha256 ${sha256}`,
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let blake3 = servedBlobs.get(sha256)?.blake3;
|
|
134
|
+
if (!blake3) {
|
|
135
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
136
|
+
blake3 = await node.import_blob(bytes);
|
|
137
|
+
}
|
|
138
|
+
scheduleRelease(sha256, blake3);
|
|
139
|
+
|
|
140
|
+
await sendMessage(stream, {
|
|
141
|
+
v: 1,
|
|
142
|
+
type: "blob_ready",
|
|
143
|
+
sha256,
|
|
144
|
+
blake3,
|
|
145
|
+
size: blob.size,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- per-sha download state (reactive) ---
|
|
150
|
+
|
|
151
|
+
export type BlobDownloadState = "downloading" | "pending" | "error";
|
|
152
|
+
|
|
153
|
+
// sha256 -> current download state for in-progress or failed fetches.
|
|
154
|
+
// absence = not currently tracked (either cached or not yet started).
|
|
155
|
+
const [_blobDownloadStates, _setBlobDownloadStates] = createSignal<
|
|
156
|
+
ReadonlyMap<string, BlobDownloadState>
|
|
157
|
+
>(new Map(), { equals: false });
|
|
158
|
+
|
|
159
|
+
export const blobDownloadStates = _blobDownloadStates;
|
|
160
|
+
|
|
161
|
+
function setBlobState(sha256: string, state: BlobDownloadState | null): void {
|
|
162
|
+
_setBlobDownloadStates((prev) => {
|
|
163
|
+
const next = new Map(prev);
|
|
164
|
+
if (state === null) next.delete(sha256);
|
|
165
|
+
else next.set(sha256, state);
|
|
166
|
+
return next;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- fetching side ---
|
|
171
|
+
|
|
172
|
+
export interface BlobFetchProgress {
|
|
173
|
+
sha256: string;
|
|
174
|
+
fraction: number; // 0..1
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// max concurrent outbound playlistz streams per peer. QUIC peers can reject
|
|
178
|
+
// streams if too many are opened simultaneously - keep this conservative.
|
|
179
|
+
const MAX_CONCURRENT_STREAMS_PER_PEER = 2;
|
|
180
|
+
|
|
181
|
+
// per-peer active stream count + queued waiters
|
|
182
|
+
const peerStreamCounts = new Map<string, number>();
|
|
183
|
+
const peerStreamWaiters = new Map<string, Array<() => void>>();
|
|
184
|
+
|
|
185
|
+
function acquirePeerStream(peerNodeId: string): Promise<void> {
|
|
186
|
+
const count = peerStreamCounts.get(peerNodeId) ?? 0;
|
|
187
|
+
if (count < MAX_CONCURRENT_STREAMS_PER_PEER) {
|
|
188
|
+
peerStreamCounts.set(peerNodeId, count + 1);
|
|
189
|
+
return Promise.resolve();
|
|
190
|
+
}
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
let waiters = peerStreamWaiters.get(peerNodeId);
|
|
193
|
+
if (!waiters) {
|
|
194
|
+
waiters = [];
|
|
195
|
+
peerStreamWaiters.set(peerNodeId, waiters);
|
|
196
|
+
}
|
|
197
|
+
waiters.push(resolve);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function releasePeerStream(peerNodeId: string): void {
|
|
202
|
+
const waiters = peerStreamWaiters.get(peerNodeId);
|
|
203
|
+
if (waiters && waiters.length > 0) {
|
|
204
|
+
const next = waiters.shift()!;
|
|
205
|
+
// count stays the same - the waiter takes the slot
|
|
206
|
+
next();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const count = peerStreamCounts.get(peerNodeId) ?? 1;
|
|
210
|
+
peerStreamCounts.set(peerNodeId, Math.max(0, count - 1));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// in-flight fetches deduped by sha256
|
|
214
|
+
const inflight = new Map<string, Promise<string | null>>();
|
|
215
|
+
|
|
216
|
+
// timeout for individual blob fetches (configurable by dev hook)
|
|
217
|
+
let BLOB_FETCH_TIMEOUT_MS = 30_000;
|
|
218
|
+
|
|
219
|
+
export function _devSetBlobFetchTimeout(ms: number): void {
|
|
220
|
+
BLOB_FETCH_TIMEOUT_MS = ms;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- transfer count listeners (used by sharingState for ui signals) ---
|
|
224
|
+
|
|
225
|
+
const _transferListeners = new Set<() => void>();
|
|
226
|
+
|
|
227
|
+
function notifyTransferListeners(): void {
|
|
228
|
+
for (const cb of _transferListeners) {
|
|
229
|
+
try { cb(); } catch { /* ignore listener errors */ }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function onTransferCountChange(cb: () => void): () => void {
|
|
234
|
+
_transferListeners.add(cb);
|
|
235
|
+
return () => _transferListeners.delete(cb);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function getActiveTransferCount(): number {
|
|
239
|
+
return inflight.size + activeServes;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** returns true if the blob with the given sha256 exists in the local blob store. */
|
|
243
|
+
export async function isBlobCachedLocally(
|
|
244
|
+
sha: string | undefined
|
|
245
|
+
): Promise<boolean> {
|
|
246
|
+
if (!sha) return false;
|
|
247
|
+
return (await getBlobMetadata(sha)) !== null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* fetch a blob from a specific peer. returns the stored blobId (sha256)
|
|
252
|
+
* or null on failure.
|
|
253
|
+
*/
|
|
254
|
+
async function fetchBlobFromPeer(
|
|
255
|
+
peerNodeId: string,
|
|
256
|
+
sha256: string,
|
|
257
|
+
mimeType: string,
|
|
258
|
+
onProgress?: (p: BlobFetchProgress) => void
|
|
259
|
+
): Promise<string | null> {
|
|
260
|
+
const node = getBlobNode();
|
|
261
|
+
if (!node) return null;
|
|
262
|
+
|
|
263
|
+
let blake3: string;
|
|
264
|
+
let size: number;
|
|
265
|
+
|
|
266
|
+
// throttle concurrent streams to avoid overwhelming the QUIC connection
|
|
267
|
+
await acquirePeerStream(peerNodeId);
|
|
268
|
+
// ask the peer to stage the blob for verified download
|
|
269
|
+
const stream = (await node.open_bi(
|
|
270
|
+
peerNodeId,
|
|
271
|
+
PLAYLISTZ_ALPN
|
|
272
|
+
)) as BiStreamLike;
|
|
273
|
+
try {
|
|
274
|
+
await sendMessage(stream, { v: 1, type: "blob_request", sha256 });
|
|
275
|
+
const reply = await readMessage(stream);
|
|
276
|
+
if (reply?.type !== "blob_ready") {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
blake3 = reply.blake3;
|
|
280
|
+
size = reply.size;
|
|
281
|
+
} finally {
|
|
282
|
+
stream.close();
|
|
283
|
+
releasePeerStream(peerNodeId);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// verified streaming download over the iroh-blobs ALPN
|
|
287
|
+
const parts: Uint8Array[] = [];
|
|
288
|
+
await node.download_verified_streaming(
|
|
289
|
+
peerNodeId,
|
|
290
|
+
blake3,
|
|
291
|
+
size,
|
|
292
|
+
(chunk) => {
|
|
293
|
+
// copy: the wasm-side buffer may be reused
|
|
294
|
+
parts.push(chunk.slice());
|
|
295
|
+
},
|
|
296
|
+
(fraction) => {
|
|
297
|
+
onProgress?.({ sha256, fraction });
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const blob = new Blob(parts as BlobPart[], { type: mimeType });
|
|
302
|
+
const storedId = await storeBlob(blob, mimeType);
|
|
303
|
+
if (storedId !== sha256) {
|
|
304
|
+
console.warn(
|
|
305
|
+
"[blobs] stored blob hash mismatch: expected",
|
|
306
|
+
sha256,
|
|
307
|
+
"got",
|
|
308
|
+
storedId
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return storedId;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* fetch a blob from any peer recorded in a doc's peers map.
|
|
316
|
+
* tries currently-connected peers first. resolves to the blobId or null.
|
|
317
|
+
* deduplicates concurrent fetches of the same sha256.
|
|
318
|
+
*/
|
|
319
|
+
export async function fetchBlobForDoc(
|
|
320
|
+
docId: string,
|
|
321
|
+
sha256: string,
|
|
322
|
+
mimeType: string,
|
|
323
|
+
onProgress?: (p: BlobFetchProgress) => void
|
|
324
|
+
): Promise<string | null> {
|
|
325
|
+
// already local?
|
|
326
|
+
if (await getBlobMetadata(sha256)) return sha256;
|
|
327
|
+
|
|
328
|
+
const existing = inflight.get(sha256);
|
|
329
|
+
if (existing) return existing;
|
|
330
|
+
|
|
331
|
+
// dev override: bypass real p2p transport (set by dev-hooks.ts)
|
|
332
|
+
if (import.meta.env.DEV && _devFetchOverride) {
|
|
333
|
+
setBlobState(sha256, "downloading");
|
|
334
|
+
notifyTransferListeners();
|
|
335
|
+
const devTask = _devFetchOverride(sha256, mimeType, onProgress);
|
|
336
|
+
const withTimeout = new Promise<string | null>((_, reject) => {
|
|
337
|
+
const t = setTimeout(() => reject(new Error("blob fetch timeout")), BLOB_FETCH_TIMEOUT_MS);
|
|
338
|
+
devTask.finally(() => clearTimeout(t));
|
|
339
|
+
});
|
|
340
|
+
const task = Promise.race([devTask, withTimeout]).then(
|
|
341
|
+
(r) => {
|
|
342
|
+
inflight.delete(sha256);
|
|
343
|
+
_setBlobDownloadStates((prev) => {
|
|
344
|
+
if (prev.get(sha256) === "downloading") {
|
|
345
|
+
const next = new Map(prev);
|
|
346
|
+
next.delete(sha256);
|
|
347
|
+
return next;
|
|
348
|
+
}
|
|
349
|
+
return prev;
|
|
350
|
+
});
|
|
351
|
+
notifyTransferListeners();
|
|
352
|
+
return r as string | null;
|
|
353
|
+
},
|
|
354
|
+
(err: unknown) => {
|
|
355
|
+
inflight.delete(sha256);
|
|
356
|
+
setBlobState(sha256, "error");
|
|
357
|
+
notifyTransferListeners();
|
|
358
|
+
throw err;
|
|
359
|
+
}
|
|
360
|
+
);
|
|
361
|
+
inflight.set(sha256, task);
|
|
362
|
+
return task;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const task = (async () => {
|
|
366
|
+
const myNodeId = getIdentity()?.node_id ?? "";
|
|
367
|
+
let peers: string[] = [];
|
|
368
|
+
try {
|
|
369
|
+
const handle = await findPlaylistDoc(docId as AutomergeUrl);
|
|
370
|
+
const doc = handle.doc();
|
|
371
|
+
peers = Object.keys(doc?.peers ?? {}).filter(
|
|
372
|
+
(n) => n && n !== myNodeId
|
|
373
|
+
);
|
|
374
|
+
} catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
if (peers.length === 0) return null;
|
|
378
|
+
|
|
379
|
+
// prefer peers with an active stream
|
|
380
|
+
const adapter = getIrohAdapter();
|
|
381
|
+
peers.sort((a, b) => {
|
|
382
|
+
const ca = adapter.isConnected(a) ? 0 : 1;
|
|
383
|
+
const cb = adapter.isConnected(b) ? 0 : 1;
|
|
384
|
+
return ca - cb;
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
for (const peer of peers) {
|
|
388
|
+
// try each peer up to 2 times with a short delay on first failure
|
|
389
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
390
|
+
try {
|
|
391
|
+
const result = await fetchBlobFromPeer(
|
|
392
|
+
peer,
|
|
393
|
+
sha256,
|
|
394
|
+
mimeType,
|
|
395
|
+
onProgress
|
|
396
|
+
);
|
|
397
|
+
if (result) return result;
|
|
398
|
+
break; // null result (peer doesn't have it) - no point retrying
|
|
399
|
+
} catch (err) {
|
|
400
|
+
if (attempt === 0) {
|
|
401
|
+
// brief pause before retry - transient QUIC stream errors often clear
|
|
402
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
403
|
+
} else {
|
|
404
|
+
console.warn(
|
|
405
|
+
"[blobs] fetch from peer failed (giving up):",
|
|
406
|
+
peer.slice(0, 16),
|
|
407
|
+
err
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
})();
|
|
415
|
+
|
|
416
|
+
const withTimeout = new Promise<string | null>((_, reject) => {
|
|
417
|
+
const t = setTimeout(() => reject(new Error("blob fetch timeout")), BLOB_FETCH_TIMEOUT_MS);
|
|
418
|
+
task.finally(() => clearTimeout(t));
|
|
419
|
+
});
|
|
420
|
+
const racedTask = Promise.race([task, withTimeout]) as Promise<string | null>;
|
|
421
|
+
inflight.set(sha256, racedTask);
|
|
422
|
+
setBlobState(sha256, "downloading");
|
|
423
|
+
notifyTransferListeners();
|
|
424
|
+
try {
|
|
425
|
+
const result = await racedTask;
|
|
426
|
+
return result;
|
|
427
|
+
} catch {
|
|
428
|
+
setBlobState(sha256, "error");
|
|
429
|
+
return null;
|
|
430
|
+
} finally {
|
|
431
|
+
inflight.delete(sha256);
|
|
432
|
+
// clear downloading state on success (error state stays until next attempt)
|
|
433
|
+
_setBlobDownloadStates((prev) => {
|
|
434
|
+
if (prev.get(sha256) === "downloading") {
|
|
435
|
+
const next = new Map(prev);
|
|
436
|
+
next.delete(sha256);
|
|
437
|
+
return next;
|
|
438
|
+
}
|
|
439
|
+
return prev;
|
|
440
|
+
});
|
|
441
|
+
notifyTransferListeners();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* fetch a song's audio blob from the peers of its playlist doc.
|
|
447
|
+
* song.playlistId is the docId for doc-backed songs.
|
|
448
|
+
*/
|
|
449
|
+
export async function fetchSongBlob(
|
|
450
|
+
song: Song,
|
|
451
|
+
onProgress?: (p: BlobFetchProgress) => void
|
|
452
|
+
): Promise<string | null> {
|
|
453
|
+
const sha = song.sha ?? song.sha256;
|
|
454
|
+
if (!sha || !song.playlistId) return null;
|
|
455
|
+
return fetchBlobForDoc(
|
|
456
|
+
song.playlistId,
|
|
457
|
+
sha,
|
|
458
|
+
song.mimeType || "audio/mpeg",
|
|
459
|
+
onProgress
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// --- prefetch + save offline ---
|
|
464
|
+
|
|
465
|
+
// upcoming-playback prefetch window
|
|
466
|
+
const PREFETCH_WINDOW_SECONDS = 30 * 60;
|
|
467
|
+
const PREFETCH_CONCURRENCY = 3;
|
|
468
|
+
|
|
469
|
+
let prefetchRun = 0;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* prefetch audio blobs for upcoming songs in a playlist, starting after
|
|
473
|
+
* the given song, until ~30 minutes of playback are locally available.
|
|
474
|
+
* currentSongRemaining: seconds left in the currently-playing song - this
|
|
475
|
+
* is included in the budget so the window is always relative to now, not
|
|
476
|
+
* the start of the next song.
|
|
477
|
+
* fire-and-forget; a new call cancels the previous run.
|
|
478
|
+
*/
|
|
479
|
+
export function prefetchUpcoming(playlist: Playlist, currentSongId: string, currentSongRemaining = 0): void {
|
|
480
|
+
const run = ++prefetchRun;
|
|
481
|
+
void (async () => {
|
|
482
|
+
const songs = await getSongsForPlaylist(playlist.id).catch(
|
|
483
|
+
() => [] as Song[]
|
|
484
|
+
);
|
|
485
|
+
const startIdx = songs.findIndex((s) => s.id === currentSongId);
|
|
486
|
+
if (startIdx === -1) return;
|
|
487
|
+
|
|
488
|
+
// collect songs within the budget window that need fetching
|
|
489
|
+
let budget = PREFETCH_WINDOW_SECONDS - currentSongRemaining;
|
|
490
|
+
const toFetch: Song[] = [];
|
|
491
|
+
const pendingShas: string[] = [];
|
|
492
|
+
|
|
493
|
+
const clearPending = () => {
|
|
494
|
+
for (const sha of pendingShas) {
|
|
495
|
+
_setBlobDownloadStates((prev) => {
|
|
496
|
+
if (prev.get(sha) === "pending") {
|
|
497
|
+
const next = new Map(prev);
|
|
498
|
+
next.delete(sha);
|
|
499
|
+
return next;
|
|
500
|
+
}
|
|
501
|
+
return prev;
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
for (let i = startIdx + 1; i < songs.length && budget > 0; i++) {
|
|
507
|
+
if (run !== prefetchRun) {
|
|
508
|
+
clearPending();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const song = songs[i]!;
|
|
512
|
+
budget -= song.duration || 0;
|
|
513
|
+
const sha = song.sha ?? song.sha256;
|
|
514
|
+
if (!sha) continue;
|
|
515
|
+
if (await getBlobMetadata(sha)) continue; // already local
|
|
516
|
+
setBlobState(sha, "pending");
|
|
517
|
+
pendingShas.push(sha);
|
|
518
|
+
toFetch.push(song);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// fetch in concurrent batches
|
|
522
|
+
for (let i = 0; i < toFetch.length; i += PREFETCH_CONCURRENCY) {
|
|
523
|
+
if (run !== prefetchRun) {
|
|
524
|
+
clearPending();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const batch = toFetch.slice(i, i + PREFETCH_CONCURRENCY);
|
|
528
|
+
await Promise.allSettled(batch.map((s) => fetchSongBlob(s)));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// clear any remaining pending states after normal completion
|
|
532
|
+
clearPending();
|
|
533
|
+
})();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export interface OfflineProgress {
|
|
537
|
+
done: number;
|
|
538
|
+
total: number;
|
|
539
|
+
currentTitle: string;
|
|
540
|
+
fraction: number; // overall 0..1
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* fetch every missing blob (audio + images) for a playlist so it can
|
|
545
|
+
* play fully offline. sequential, with per-item progress callbacks.
|
|
546
|
+
* returns the number of blobs fetched (0 = everything was local).
|
|
547
|
+
*/
|
|
548
|
+
export async function savePlaylistOffline(
|
|
549
|
+
playlist: Playlist,
|
|
550
|
+
onProgress?: (p: OfflineProgress) => void
|
|
551
|
+
): Promise<number> {
|
|
552
|
+
const docId = playlist.id;
|
|
553
|
+
const missing = await collectMissingBlobs(playlist);
|
|
554
|
+
|
|
555
|
+
let fetched = 0;
|
|
556
|
+
for (let i = 0; i < missing.length; i++) {
|
|
557
|
+
const item = missing[i]!;
|
|
558
|
+
onProgress?.({
|
|
559
|
+
done: i,
|
|
560
|
+
total: missing.length,
|
|
561
|
+
currentTitle: item.title,
|
|
562
|
+
fraction: missing.length === 0 ? 1 : i / missing.length,
|
|
563
|
+
});
|
|
564
|
+
const result = await fetchBlobForDoc(docId, item.sha, item.mime, (p) => {
|
|
565
|
+
onProgress?.({
|
|
566
|
+
done: i,
|
|
567
|
+
total: missing.length,
|
|
568
|
+
currentTitle: item.title,
|
|
569
|
+
fraction: (i + p.fraction) / missing.length,
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
if (result) fetched++;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
onProgress?.({
|
|
576
|
+
done: missing.length,
|
|
577
|
+
total: missing.length,
|
|
578
|
+
currentTitle: "",
|
|
579
|
+
fraction: 1,
|
|
580
|
+
});
|
|
581
|
+
return fetched;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* true when any blob the playlist references (audio or images) is not
|
|
586
|
+
* yet in the local blob store. used to hide "save offline" once a
|
|
587
|
+
* playlist is fully cached.
|
|
588
|
+
*/
|
|
589
|
+
export async function playlistHasMissingBlobs(
|
|
590
|
+
playlist: Playlist
|
|
591
|
+
): Promise<boolean> {
|
|
592
|
+
const missing = await collectMissingBlobs(playlist);
|
|
593
|
+
return missing.length > 0;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// gather every blob a playlist references (song audio, song images,
|
|
597
|
+
// playlist covers), deduped, and return the subset missing locally.
|
|
598
|
+
// cover images come first so the playlist looks good as soon as possible.
|
|
599
|
+
async function collectMissingBlobs(
|
|
600
|
+
playlist: Playlist
|
|
601
|
+
): Promise<{ sha: string; mime: string; title: string }[]> {
|
|
602
|
+
const docId = playlist.id;
|
|
603
|
+
const coverItems: { sha: string; mime: string; title: string }[] = [];
|
|
604
|
+
const audioItems: { sha: string; mime: string; title: string }[] = [];
|
|
605
|
+
const imageItems: { sha: string; mime: string; title: string }[] = [];
|
|
606
|
+
|
|
607
|
+
const songs = await getSongsForPlaylist(docId).catch(() => [] as Song[]);
|
|
608
|
+
for (const song of songs) {
|
|
609
|
+
const sha = song.sha ?? song.sha256;
|
|
610
|
+
if (sha) {
|
|
611
|
+
audioItems.push({
|
|
612
|
+
sha,
|
|
613
|
+
mime: song.mimeType || "audio/mpeg",
|
|
614
|
+
title: song.title,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
for (const img of song.images ?? []) {
|
|
618
|
+
if (img.blobId) {
|
|
619
|
+
imageItems.push({
|
|
620
|
+
sha: img.blobId,
|
|
621
|
+
mime: "image/jpeg",
|
|
622
|
+
title: `${song.title} (image)`,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// playlist cover images - fetched before song audio for fast visual loading
|
|
629
|
+
try {
|
|
630
|
+
const handle = await findPlaylistDoc(docId as AutomergeUrl);
|
|
631
|
+
const doc = handle.doc();
|
|
632
|
+
for (const img of doc?.images ?? []) {
|
|
633
|
+
if (img.blobId) {
|
|
634
|
+
coverItems.push({
|
|
635
|
+
sha: img.blobId,
|
|
636
|
+
mime: "image/jpeg",
|
|
637
|
+
title: "playlist cover",
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
} catch {
|
|
642
|
+
// doc unavailable - song list already covers most blobs
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// dedupe: covers → song images → audio
|
|
646
|
+
const wanted = [...coverItems, ...imageItems, ...audioItems];
|
|
647
|
+
const seen = new Set<string>();
|
|
648
|
+
const missing: typeof wanted = [];
|
|
649
|
+
for (const item of wanted) {
|
|
650
|
+
if (seen.has(item.sha)) continue;
|
|
651
|
+
seen.add(item.sha);
|
|
652
|
+
if (!(await getBlobMetadata(item.sha))) {
|
|
653
|
+
missing.push(item);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return missing;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** reset module state. for use in tests only. */
|
|
660
|
+
export function _resetBlobTransferForTests(): void {
|
|
661
|
+
for (const { releaseTimer } of servedBlobs.values()) {
|
|
662
|
+
clearTimeout(releaseTimer);
|
|
663
|
+
}
|
|
664
|
+
servedBlobs.clear();
|
|
665
|
+
inflight.clear();
|
|
666
|
+
_setBlobDownloadStates(new Map());
|
|
667
|
+
prefetchRun++;
|
|
668
|
+
_devFetchOverride = null;
|
|
669
|
+
BLOB_FETCH_TIMEOUT_MS = 30_000;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// --- dev hook slot (implementation lives in src/dev-hooks.ts) ---
|
|
673
|
+
|
|
674
|
+
// override function for fetchBlobForDoc - set by dev-hooks.ts in DEV builds only.
|
|
675
|
+
// checked under `import.meta.env.DEV` so the branch is eliminated in production.
|
|
676
|
+
let _devFetchOverride: (
|
|
677
|
+
| ((
|
|
678
|
+
sha256: string,
|
|
679
|
+
mimeType: string,
|
|
680
|
+
onProgress?: (p: BlobFetchProgress) => void
|
|
681
|
+
) => Promise<string | null>)
|
|
682
|
+
| null
|
|
683
|
+
) = null;
|
|
684
|
+
|
|
685
|
+
// set the fetch override (called from dev-hooks.ts)
|
|
686
|
+
export function _devSetFetchOverride(
|
|
687
|
+
fn: typeof _devFetchOverride
|
|
688
|
+
): void {
|
|
689
|
+
_devFetchOverride = fn;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// evict a blob from local store - for simulating cache misses in tests
|
|
693
|
+
export async function _devEvictBlob(sha256: string): Promise<void> {
|
|
694
|
+
const { deleteBlob } = await import("@freqhole/api-client/storage");
|
|
695
|
+
await deleteBlob(sha256).catch(() => {});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// fetch a blob directly by sha256 - used in tests to trigger retry without a UI click.
|
|
699
|
+
// passes an empty docId because mock overrides don't use it.
|
|
700
|
+
export async function _devFetchBlobBySha(sha256: string): Promise<string | null> {
|
|
701
|
+
return fetchBlobForDoc("", sha256, "audio/wav");
|
|
702
|
+
}
|