Stream purchased files into durable cache and avoid duplicate concurrent payments

This commit is contained in:
archipelago
2026-10-06 05:48:05 -04:00
parent df7677d23f
commit 9ce04627dd
9 changed files with 709 additions and 178 deletions
@@ -75,6 +75,26 @@ describe('usePaidItemViewer — UIFIX-04 (lightbox routing) + UIFIX-06 (loading/
vi.stubGlobal('atob', vi.fn(() => 'binarydata'))
})
it('opens a large cached video through authenticated HTTP without decoding or a blob allocation', async () => {
mockedRpc.call.mockResolvedValue({ owned: true, mime_type: 'video/mp4', size_bytes: 200000000 })
const viewer = usePaidItemViewer()
await viewer.open(VIDEO_ITEM)
expect(await viewer.resolveBlobUrl(viewer.lightboxItems.value[0]!.path)).toBe('/api/peer-content/abc123.onion/content-2')
expect(createObjectURLSpy).not.toHaveBeenCalled()
expect(atob).not.toHaveBeenCalled()
expect(mockedRpc.call).toHaveBeenCalledWith(expect.objectContaining({ params: { onion: VIDEO_ITEM.onion, content_id: VIDEO_ITEM.content_id, cache_only: true } }))
})
it('does not open incomplete delivery or start another purchase', async () => {
mockedRpc.call.mockRejectedValue(new Error('Delivery incomplete'))
const viewer = usePaidItemViewer()
await viewer.open(VIDEO_ITEM)
expect(viewer.lightboxIndex.value).toBeNull()
expect(viewer.error.value).toBeTruthy()
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('routes an image mime to the lightbox, not window.open', async () => {
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
const viewer = usePaidItemViewer()
+11 -12
View File
@@ -22,7 +22,7 @@ export function paidItemKey(it: OwnedItemLike): string {
}
/**
* Fetch, decode, route-to-viewer and loading/error state for a purchased
* Validate ownership, stream to viewer and manage loading/error state for a purchased
* item (UIFIX-04 / UIFIX-06).
*
* - image/video → routed into the caller's MediaLightbox via `lightboxItems`
@@ -42,7 +42,7 @@ export function usePaidItemViewer() {
const lightboxItems = ref<FileBrowserItem[]>([])
const lightboxIndex = ref<number | null>(null)
// Synthetic-path -> already-fetched blob URL. Populated only for items
// Synthetic-path -> authenticated cache URL (or legacy blob URL). Populated only for items
// routed to the lightbox; resolveBlobUrl reads from here and never fetches.
const urlByPath = new Map<string, string>()
// One in-flight fetch per item key — a second open() for the same key
@@ -61,21 +61,20 @@ export function usePaidItemViewer() {
opening.value = key
error.value = null
try {
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
const res = await rpcClient.call<{ data_base64?: string; data?: string; owned?: boolean; mime_type?: string; error?: string }>({
method: 'content.owned-get',
params: { onion: it.onion, content_id: it.content_id },
params: { onion: it.onion, content_id: it.content_id, cache_only: true },
timeout: 60000,
})
const b64 = res.data_base64 || res.data
if (!b64) {
error.value = "Couldn't open this file — the peer returned no data."
const b64 = res.data_base64 ?? res.data
if (b64 === undefined && res.owned !== true) {
error.value = res.error || "Couldn't open this file — its cached copy is unavailable."
return
}
const bin = atob(b64)
const arr = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
const mime = res.mime_type || it.mime_type
const url = URL.createObjectURL(new Blob([arr], { type: mime }))
const url = b64 === undefined
? `/api/peer-content/${encodeURIComponent(it.onion)}/${encodeURIComponent(it.content_id)}`
: URL.createObjectURL(new Blob([Uint8Array.from(atob(b64), c => c.charCodeAt(0))], { type: mime }))
// Music ALWAYS plays in the global bottom-bar player — never a
// lightbox (blob URL stays alive for the bar; it owns playback now).
@@ -104,7 +103,7 @@ export function usePaidItemViewer() {
// No in-app viewer (documents, etc.) — keep today's behaviour.
window.open(url, '_blank', 'noopener')
setTimeout(() => URL.revokeObjectURL(url), 60000)
if (url.startsWith("blob:")) setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch {
error.value = "Couldn't open this file — it may be unavailable right now."
} finally {