mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(client): report progress while an iOS web app buffers a download
A home-screen web app cannot follow a streaming attachment link, so it buffers the whole file first, and buffering said nothing: a large book is about 45 seconds of a static UI. Read the body through a reader and render the byte count as a progress toast, keeping the Content-Type so WebKit still names the file. Also default the book detail cover tint to off. Refs #1159
This commit is contained in:
@@ -22,7 +22,7 @@ function resetDisplaySettings() {
|
||||
settings.showSpineOnComics.value = false
|
||||
settings.bookShadowStrength.value = 'default'
|
||||
settings.bookCoverDisplayMode.value = 'blurred-fit'
|
||||
settings.bookDetailCoverTint.value = 'single'
|
||||
settings.bookDetailCoverTint.value = 'off'
|
||||
settings.seriesCardCoverMode.value = 'stack'
|
||||
settings.gridCardPrimaryLabel.value = 'hidden'
|
||||
settings.gridCardSecondaryLabel.value = 'hidden'
|
||||
@@ -207,9 +207,9 @@ describe('useDisplaySettings preferences helpers', () => {
|
||||
expect(getDisplayPreferencesSnapshot().bookDetailCoverTint).toBe('duotone')
|
||||
})
|
||||
|
||||
it('defaults bookDetailCoverTint to single', () => {
|
||||
it('defaults bookDetailCoverTint to off', () => {
|
||||
resetDisplaySettings()
|
||||
expect(settings.bookDetailCoverTint.value).toBe('single')
|
||||
expect(settings.bookDetailCoverTint.value).toBe('off')
|
||||
})
|
||||
|
||||
it('sanitizes bookDetailCoverTint and drops unknown values', () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ const DEFAULT_GRID_GAP = 28
|
||||
const DEFAULT_BOOK_SPINE_OVERLAY: BookSpineOverlay = 'off'
|
||||
const DEFAULT_BOOK_SHADOW_STRENGTH: BookShadowStrength = 'default'
|
||||
const DEFAULT_BOOK_COVER_DISPLAY_MODE: BookCoverDisplayMode = 'blurred-fit'
|
||||
const DEFAULT_BOOK_DETAIL_COVER_TINT: BookDetailCoverTint = 'single'
|
||||
const DEFAULT_BOOK_DETAIL_COVER_TINT: BookDetailCoverTint = 'off'
|
||||
const DEFAULT_SERIES_CARD_COVER_MODE: SeriesCardCoverMode = 'stack'
|
||||
const DEFAULT_BOOK_THUMBNAIL_CLICK_ACTION: BookThumbnailClickAction = 'reader'
|
||||
const DEFAULT_CARD_OVERLAYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'series-position']
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DownloadProgress } from '@/lib/download'
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
loading: vi.fn<(message: string) => string>().mockReturnValue('toast-id'),
|
||||
loading: vi.fn<(message: string, data?: { id?: string | number }) => string>().mockReturnValue('toast-id'),
|
||||
dismiss: vi.fn<(id: string) => void>(),
|
||||
error: vi.fn<(message: string) => void>(),
|
||||
}))
|
||||
|
||||
const downloadMock = vi.hoisted(() => ({
|
||||
downloadFromUrl: vi.fn<(url: string, fallbackFileName: string) => Promise<void>>().mockResolvedValue(undefined),
|
||||
downloadFromUrl: vi
|
||||
.fn<(url: string, fallbackFileName: string, onProgress?: (progress: DownloadProgress) => void) => Promise<void>>()
|
||||
.mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
@@ -16,10 +20,18 @@ vi.mock('vue-sonner', () => ({
|
||||
|
||||
vi.mock('@/lib/download', () => downloadMock)
|
||||
|
||||
/** The copy assertions below are English, so pin the locale rather than trusting the default. */
|
||||
async function loadComposable() {
|
||||
const { i18n } = await import('@/i18n')
|
||||
;(i18n.global.locale as unknown as { value: string }).value = 'en'
|
||||
return (await import('../useBookDownload')).useBookDownload
|
||||
}
|
||||
|
||||
describe('useBookDownload', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
toastMock.loading.mockClear()
|
||||
toastMock.loading.mockReturnValue('toast-id')
|
||||
toastMock.dismiss.mockClear()
|
||||
toastMock.error.mockClear()
|
||||
downloadMock.downloadFromUrl.mockClear()
|
||||
@@ -27,12 +39,14 @@ describe('useBookDownload', () => {
|
||||
})
|
||||
|
||||
it('downloads a single file from its download endpoint', async () => {
|
||||
const { useBookDownload } = await import('../useBookDownload')
|
||||
const useBookDownload = await loadComposable()
|
||||
const { downloadFile, isDownloading } = useBookDownload()
|
||||
|
||||
await downloadFile(42)
|
||||
|
||||
expect(downloadMock.downloadFromUrl).toHaveBeenCalledWith('/api/v1/books/files/42/download', 'book')
|
||||
const [url, fallback] = downloadMock.downloadFromUrl.mock.calls[0] ?? []
|
||||
expect(url).toBe('/api/v1/books/files/42/download')
|
||||
expect(fallback).toBe('book')
|
||||
expect(toastMock.error).not.toHaveBeenCalled()
|
||||
expect(isDownloading.value).toBe(false)
|
||||
})
|
||||
@@ -40,7 +54,7 @@ describe('useBookDownload', () => {
|
||||
it('reports a failed single-file download and clears the busy flag', async () => {
|
||||
downloadMock.downloadFromUrl.mockRejectedValue(new Error('Download failed with status 403'))
|
||||
|
||||
const { useBookDownload } = await import('../useBookDownload')
|
||||
const useBookDownload = await loadComposable()
|
||||
const { downloadFile, isDownloading } = useBookDownload()
|
||||
|
||||
await downloadFile(42)
|
||||
@@ -50,7 +64,7 @@ describe('useBookDownload', () => {
|
||||
})
|
||||
|
||||
it('uses primary export scope by default', async () => {
|
||||
const { useBookDownload } = await import('../useBookDownload')
|
||||
const useBookDownload = await loadComposable()
|
||||
const { exportBooks } = useBookDownload()
|
||||
|
||||
await exportBooks([1, 2], false)
|
||||
@@ -63,7 +77,7 @@ describe('useBookDownload', () => {
|
||||
})
|
||||
|
||||
it('uses all-formats scope when requested', async () => {
|
||||
const { useBookDownload } = await import('../useBookDownload')
|
||||
const useBookDownload = await loadComposable()
|
||||
const { exportBooks } = useBookDownload()
|
||||
|
||||
await exportBooks([5], true)
|
||||
@@ -72,7 +86,7 @@ describe('useBookDownload', () => {
|
||||
})
|
||||
|
||||
it('supports audio-only export scope override', async () => {
|
||||
const { useBookDownload } = await import('../useBookDownload')
|
||||
const useBookDownload = await loadComposable()
|
||||
const { exportBooks } = useBookDownload()
|
||||
|
||||
await exportBooks([9], false, 'audio')
|
||||
@@ -88,7 +102,7 @@ describe('useBookDownload', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const { useBookDownload } = await import('../useBookDownload')
|
||||
const useBookDownload = await loadComposable()
|
||||
const { exportBooks } = useBookDownload()
|
||||
|
||||
const pending = exportBooks([1], false)
|
||||
@@ -103,7 +117,7 @@ describe('useBookDownload', () => {
|
||||
it('reports a failed export', async () => {
|
||||
downloadMock.downloadFromUrl.mockRejectedValue(new Error('Download failed with status 500'))
|
||||
|
||||
const { useBookDownload } = await import('../useBookDownload')
|
||||
const useBookDownload = await loadComposable()
|
||||
const { exportBooks, isDownloading } = useBookDownload()
|
||||
|
||||
await exportBooks([1], false)
|
||||
@@ -113,3 +127,146 @@ describe('useBookDownload', () => {
|
||||
expect(isDownloading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBookDownload progress reporting', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
toastMock.loading.mockClear()
|
||||
toastMock.loading.mockReturnValue('toast-id')
|
||||
toastMock.dismiss.mockClear()
|
||||
toastMock.error.mockClear()
|
||||
downloadMock.downloadFromUrl.mockClear()
|
||||
downloadMock.downloadFromUrl.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('says nothing for a single file when no progress is reported', async () => {
|
||||
const useBookDownload = await loadComposable()
|
||||
const { downloadFile } = useBookDownload()
|
||||
|
||||
await downloadFile(42)
|
||||
|
||||
expect(toastMock.loading).not.toHaveBeenCalled()
|
||||
expect(toastMock.dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('raises a toast as soon as a buffered single-file download starts', async () => {
|
||||
downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => {
|
||||
onProgress?.({ loaded: 0, total: null })
|
||||
})
|
||||
|
||||
const useBookDownload = await loadComposable()
|
||||
const { downloadFile } = useBookDownload()
|
||||
|
||||
await downloadFile(42)
|
||||
|
||||
expect(toastMock.loading).toHaveBeenCalledWith('Preparing download...')
|
||||
expect(toastMock.dismiss).toHaveBeenCalledWith('toast-id')
|
||||
})
|
||||
|
||||
it('shows how far along a measurable download is', async () => {
|
||||
downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => {
|
||||
onProgress?.({ loaded: 0, total: null })
|
||||
onProgress?.({ loaded: 0, total: 100 * 1024 * 1024 })
|
||||
onProgress?.({ loaded: 42 * 1024 * 1024, total: 100 * 1024 * 1024 })
|
||||
})
|
||||
|
||||
const useBookDownload = await loadComposable()
|
||||
const { downloadFile } = useBookDownload()
|
||||
|
||||
await downloadFile(42)
|
||||
|
||||
expect(toastMock.loading.mock.calls.map((call) => call[0])).toEqual([
|
||||
'Preparing download...',
|
||||
'Downloading 0% of 100 MB',
|
||||
'Downloading 42% of 100 MB',
|
||||
])
|
||||
})
|
||||
|
||||
it('updates the one toast in place instead of stacking a new one per chunk', async () => {
|
||||
downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => {
|
||||
onProgress?.({ loaded: 0, total: 1000 })
|
||||
onProgress?.({ loaded: 500, total: 1000 })
|
||||
})
|
||||
|
||||
const useBookDownload = await loadComposable()
|
||||
const { downloadFile } = useBookDownload()
|
||||
|
||||
await downloadFile(42)
|
||||
|
||||
expect(toastMock.loading.mock.calls[0]?.[1]).toBeUndefined()
|
||||
expect(toastMock.loading.mock.calls[1]?.[1]).toEqual({ id: 'toast-id' })
|
||||
expect(toastMock.dismiss).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('collapses the thousands of chunks inside one percent into a single update', async () => {
|
||||
const total = 100 * 1024 * 1024
|
||||
downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => {
|
||||
// Every 64KB chunk across the first two percent: ~32 reports, two distinct messages.
|
||||
for (let loaded = 0; loaded <= total * 0.02; loaded += 64 * 1024) onProgress?.({ loaded, total })
|
||||
})
|
||||
|
||||
const useBookDownload = await loadComposable()
|
||||
const { downloadFile } = useBookDownload()
|
||||
|
||||
await downloadFile(42)
|
||||
|
||||
expect(toastMock.loading.mock.calls.map((call) => call[0])).toEqual([
|
||||
'Downloading 0% of 100 MB',
|
||||
'Downloading 1% of 100 MB',
|
||||
'Downloading 2% of 100 MB',
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to transferred bytes when the response declares no length', async () => {
|
||||
downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => {
|
||||
onProgress?.({ loaded: 0, total: null })
|
||||
onProgress?.({ loaded: 5 * 1024 * 1024, total: null })
|
||||
})
|
||||
|
||||
const useBookDownload = await loadComposable()
|
||||
const { downloadFile } = useBookDownload()
|
||||
|
||||
await downloadFile(42)
|
||||
|
||||
expect(toastMock.loading.mock.calls.map((call) => call[0])).toEqual(['Preparing download...', 'Downloading 5 MB'])
|
||||
})
|
||||
|
||||
it('keeps the export label until real progress replaces it', async () => {
|
||||
downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => {
|
||||
onProgress?.({ loaded: 0, total: null })
|
||||
onProgress?.({ loaded: 25, total: 100 })
|
||||
})
|
||||
|
||||
const useBookDownload = await loadComposable()
|
||||
const { exportBooks } = useBookDownload()
|
||||
|
||||
await exportBooks([1, 2, 3], false)
|
||||
|
||||
expect(toastMock.loading.mock.calls.map((call) => call[0])).toEqual(['Preparing 3 books for download...', 'Downloading 25% of 100 B'])
|
||||
})
|
||||
|
||||
it('pluralises the export label for a single book', async () => {
|
||||
const useBookDownload = await loadComposable()
|
||||
const { exportBooks } = useBookDownload()
|
||||
|
||||
await exportBooks([7], false)
|
||||
|
||||
expect(toastMock.loading).toHaveBeenCalledWith('Preparing 1 book for download...')
|
||||
})
|
||||
|
||||
it('dismisses the progress toast when a buffered download fails', async () => {
|
||||
downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => {
|
||||
onProgress?.({ loaded: 0, total: null })
|
||||
throw new Error('Download failed with status 500')
|
||||
})
|
||||
|
||||
const useBookDownload = await loadComposable()
|
||||
const { downloadFile, isDownloading } = useBookDownload()
|
||||
|
||||
await downloadFile(42)
|
||||
|
||||
expect(toastMock.error).toHaveBeenCalledWith('Download failed')
|
||||
expect(toastMock.dismiss).toHaveBeenCalledWith('toast-id')
|
||||
expect(isDownloading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,81 @@
|
||||
import { ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import { formatPercent } from '@/i18n/formatters'
|
||||
import type { DownloadProgress } from '@/lib/download'
|
||||
import { downloadFromUrl } from '@/lib/download'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
|
||||
type ExportScope = 'primary' | 'all' | 'audio'
|
||||
|
||||
// Reached through the global instance rather than `useI18n()` because `useBookDownload` is also
|
||||
// called from inside other composables, where there is no setup scope to bind to.
|
||||
function t(key: string, named?: Record<string, unknown>): string {
|
||||
return named ? i18n.global.t(key, named) : i18n.global.t(key)
|
||||
}
|
||||
|
||||
/** Null while nothing more useful than "we have started" is known yet. */
|
||||
function progressMessage(progress: DownloadProgress): string | null {
|
||||
if (progress.total === null) {
|
||||
return progress.loaded > 0 ? t('book.download.progressBytes', { transferred: formatBytes(progress.loaded) }) : null
|
||||
}
|
||||
return t('book.download.progressPercent', {
|
||||
percent: formatPercent(progress.loaded / progress.total),
|
||||
total: formatBytes(progress.total),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A large file arrives as thousands of chunks, so the rendered message is the throttle: at whole
|
||||
* percents it settles to at most one toast update per percent instead of one per chunk.
|
||||
*
|
||||
* `showImmediately` separates the two waits. An export has to be built server-side before a byte
|
||||
* moves, so it announces itself up front; a single file streams at once, and only says anything
|
||||
* when the download is one we are buffering ourselves. Everywhere else the browser's own download
|
||||
* UI is already the indicator, and a toast would just duplicate it.
|
||||
*/
|
||||
function createDownloadToast(pendingMessage: string, showImmediately: boolean) {
|
||||
let id: string | number | undefined
|
||||
let shown: string | null = null
|
||||
|
||||
function render(message: string) {
|
||||
if (message === shown) return
|
||||
shown = message
|
||||
id = id === undefined ? toast.loading(message) : toast.loading(message, { id })
|
||||
}
|
||||
|
||||
if (showImmediately) render(pendingMessage)
|
||||
|
||||
return {
|
||||
report(progress: DownloadProgress) {
|
||||
render(progressMessage(progress) ?? pendingMessage)
|
||||
},
|
||||
dismiss() {
|
||||
if (id !== undefined) toast.dismiss(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useBookDownload() {
|
||||
const isDownloading = ref(false)
|
||||
|
||||
async function downloadFile(fileId: number): Promise<void> {
|
||||
const progress = createDownloadToast(t('book.download.preparing'), false)
|
||||
isDownloading.value = true
|
||||
try {
|
||||
await downloadFromUrl(`/api/v1/books/files/${fileId}/download`, 'book')
|
||||
await downloadFromUrl(`/api/v1/books/files/${fileId}/download`, 'book', progress.report)
|
||||
} catch {
|
||||
toast.error('Download failed')
|
||||
toast.error(t('book.download.failed'))
|
||||
} finally {
|
||||
progress.dismiss()
|
||||
isDownloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function exportBooks(bookIds: number[], allFormats: boolean, scopeOverride?: ExportScope): Promise<void> {
|
||||
if (bookIds.length === 0) return
|
||||
const label = `${bookIds.length} book${bookIds.length === 1 ? '' : 's'}`
|
||||
const toastId = toast.loading(`Preparing ${label} for download...`)
|
||||
const progress = createDownloadToast(t('book.download.preparingExport', { count: bookIds.length }), true)
|
||||
isDownloading.value = true
|
||||
try {
|
||||
const scope = scopeOverride ?? (allFormats ? 'all' : 'primary')
|
||||
@@ -30,11 +83,11 @@ export function useBookDownload() {
|
||||
bookIds: bookIds.join(','),
|
||||
scope,
|
||||
})
|
||||
await downloadFromUrl(`/api/v1/books/export/download?${params.toString()}`, 'bookorbit-export.zip')
|
||||
await downloadFromUrl(`/api/v1/books/export/download?${params.toString()}`, 'bookorbit-export.zip', progress.report)
|
||||
} catch {
|
||||
toast.error('Export failed')
|
||||
toast.error(t('book.download.exportFailed'))
|
||||
} finally {
|
||||
toast.dismiss(toastId)
|
||||
progress.dismiss()
|
||||
isDownloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
showSpineOnComics.value = false
|
||||
bookDetailCoverTint.value = 'single'
|
||||
bookDetailCoverTint.value = 'off'
|
||||
})
|
||||
|
||||
describe('AppearanceBookCoverSettings', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DownloadProgress } from '../download'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>() }))
|
||||
|
||||
vi.mock('@/lib/api', () => ({ api: mocks.api }))
|
||||
@@ -8,6 +10,22 @@ function setStandalone(value: boolean | undefined) {
|
||||
Object.defineProperty(navigator, 'standalone', { value, configurable: true, writable: true })
|
||||
}
|
||||
|
||||
/** A Response whose body arrives as the given chunks, one `read()` at a time. */
|
||||
function streamingResponse(chunks: Uint8Array[], headers: Record<string, string>) {
|
||||
let index = 0
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(headers),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => (index < chunks.length ? { done: false, value: chunks[index++] } : { done: true, value: undefined }),
|
||||
}),
|
||||
},
|
||||
blob: async () => new Blob(chunks as BlobPart[], { type: headers['Content-Type'] ?? '' }),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('downloadFromUrl', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
@@ -36,6 +54,16 @@ describe('downloadFromUrl', () => {
|
||||
expect(removeSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stays silent on the link path, where the browser owns the download indicator', async () => {
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
const onProgress = vi.fn<(progress: DownloadProgress) => void>()
|
||||
|
||||
const { downloadFromUrl } = await import('../download')
|
||||
await downloadFromUrl('/api/v1/books/files/42/download', 'book', onProgress)
|
||||
|
||||
expect(onProgress).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hands an iOS home-screen web app a blob so the app is never navigated away', async () => {
|
||||
setStandalone(true)
|
||||
const appendSpy = vi.spyOn(document.body, 'append')
|
||||
@@ -101,6 +129,105 @@ describe('downloadFromUrl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadFromUrl progress on the buffered iOS path', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
mocks.api.mockReset()
|
||||
setStandalone(true)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('announces the wait before the request resolves, so a slow first byte is not silent', async () => {
|
||||
let release: ((response: Response) => void) | undefined
|
||||
mocks.api.mockReturnValue(
|
||||
new Promise<Response>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
const seen: DownloadProgress[] = []
|
||||
|
||||
const { downloadFromUrl } = await import('../download')
|
||||
const pending = downloadFromUrl('/api/v1/books/files/42/download', 'book', (p) => seen.push({ ...p }))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(seen).toEqual([{ loaded: 0, total: null }])
|
||||
|
||||
release?.(streamingResponse([new Uint8Array([1])], { 'Content-Length': '1' }))
|
||||
await pending
|
||||
})
|
||||
|
||||
it('reports cumulative bytes against the declared length as the body arrives', async () => {
|
||||
mocks.api.mockResolvedValue(
|
||||
streamingResponse([new Uint8Array(40), new Uint8Array(35), new Uint8Array(25)], {
|
||||
'Content-Length': '100',
|
||||
'Content-Type': 'application/epub+zip',
|
||||
}),
|
||||
)
|
||||
const seen: DownloadProgress[] = []
|
||||
|
||||
const { downloadFromUrl } = await import('../download')
|
||||
await downloadFromUrl('/api/v1/books/files/42/download', 'book', (p) => seen.push({ ...p }))
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ loaded: 0, total: null },
|
||||
{ loaded: 0, total: 100 },
|
||||
{ loaded: 40, total: 100 },
|
||||
{ loaded: 75, total: 100 },
|
||||
{ loaded: 100, total: 100 },
|
||||
])
|
||||
})
|
||||
|
||||
it('reassembles the streamed chunks into the original bytes and content type', async () => {
|
||||
const chunks = [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5])]
|
||||
mocks.api.mockResolvedValue(streamingResponse(chunks, { 'Content-Length': '5', 'Content-Type': 'application/epub+zip' }))
|
||||
let handed: Blob | undefined
|
||||
vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
|
||||
handed = blob as Blob
|
||||
return 'blob:test-url'
|
||||
})
|
||||
|
||||
const { downloadFromUrl } = await import('../download')
|
||||
await downloadFromUrl('/api/v1/books/files/42/download', 'book', () => {})
|
||||
|
||||
expect(handed?.type).toBe('application/epub+zip')
|
||||
expect(new Uint8Array(await handed!.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4, 5]))
|
||||
})
|
||||
|
||||
it('leaves the total null when the response declares no usable length', async () => {
|
||||
mocks.api.mockResolvedValue(streamingResponse([new Uint8Array(8)], { 'Content-Length': '0' }))
|
||||
const seen: DownloadProgress[] = []
|
||||
|
||||
const { downloadFromUrl } = await import('../download')
|
||||
await downloadFromUrl('/api/v1/books/files/42/download', 'book', (p) => seen.push({ ...p }))
|
||||
|
||||
expect(seen.every((p) => p.total === null)).toBe(true)
|
||||
expect(seen.at(-1)).toEqual({ loaded: 8, total: null })
|
||||
})
|
||||
|
||||
it('still downloads when the response exposes no readable body', async () => {
|
||||
mocks.api.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'Content-Length': '4', 'Content-Type': 'application/epub+zip' }),
|
||||
blob: async () => new Blob(['epub'], { type: 'application/epub+zip' }),
|
||||
} as unknown as Response)
|
||||
const appendSpy = vi.spyOn(document.body, 'append')
|
||||
const seen: DownloadProgress[] = []
|
||||
|
||||
const { downloadFromUrl } = await import('../download')
|
||||
await downloadFromUrl('/api/v1/books/files/42/download', 'book', (p) => seen.push({ ...p }))
|
||||
|
||||
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.href).toBe('blob:test-url')
|
||||
expect(seen.at(-1)).toEqual({ loaded: 0, total: 4 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('fileNameFromContentDisposition', () => {
|
||||
it('prefers the UTF-8 filename, falls back to ASCII, then to the caller fallback', async () => {
|
||||
const { fileNameFromContentDisposition } = await import('../download')
|
||||
|
||||
@@ -15,6 +15,9 @@ function isIosStandaloneWebApp(): boolean {
|
||||
/** Revoking while the browser is still reading the blob cancels the download, so outlive the click. */
|
||||
const OBJECT_URL_TTL_MS = 60_000
|
||||
|
||||
/** `total` is null when the response arrives without a usable `Content-Length`. */
|
||||
export type DownloadProgress = { loaded: number; total: number | null }
|
||||
|
||||
export function fileNameFromContentDisposition(disposition: string | null, fallback: string): string {
|
||||
if (!disposition) return fallback
|
||||
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
@@ -41,12 +44,50 @@ export function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||
setTimeout(() => URL.revokeObjectURL(url), OBJECT_URL_TTL_MS)
|
||||
}
|
||||
|
||||
function declaredLength(response: Response): number | null {
|
||||
const header = Number(response.headers.get('Content-Length'))
|
||||
return Number.isFinite(header) && header > 0 ? header : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffers the body a chunk at a time so the caller can report how far along a large file is.
|
||||
* `response.blob()` would be shorter, but it resolves only once the last byte has landed, which
|
||||
* on a phone connection is a minute of the UI having nothing to say.
|
||||
*/
|
||||
async function readBlobWithProgress(response: Response, onProgress: (progress: DownloadProgress) => void): Promise<Blob> {
|
||||
const total = declaredLength(response)
|
||||
const reader = response.body?.getReader?.()
|
||||
// A body-less Response still has to download, it just cannot be measured.
|
||||
if (!reader) {
|
||||
onProgress({ loaded: 0, total })
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') ?? ''
|
||||
const chunks: BlobPart[] = []
|
||||
let loaded = 0
|
||||
onProgress({ loaded, total })
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value as BlobPart)
|
||||
loaded += value.byteLength
|
||||
onProgress({ loaded, total })
|
||||
}
|
||||
// `response.blob()` takes the type from the header, so reassembling by hand has to as well or
|
||||
// WebKit loses the file kind and offers the sheet as an unnamed binary.
|
||||
return new Blob(chunks, { type: contentType })
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an attachment endpoint. Everywhere the browser owns a window it can navigate away
|
||||
* from, the link streams straight from the server so nothing has to be buffered; only the
|
||||
* trapped iOS web app pays for a blob.
|
||||
*
|
||||
* `onProgress` fires only on that buffered path, because it is the only one where the wait is
|
||||
* ours to report: a plain link hands off to the browser's own download UI immediately.
|
||||
*/
|
||||
export async function downloadFromUrl(url: string, fallbackFileName: string): Promise<void> {
|
||||
export async function downloadFromUrl(url: string, fallbackFileName: string, onProgress?: (progress: DownloadProgress) => void): Promise<void> {
|
||||
if (!isIosStandaloneWebApp()) {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
@@ -57,8 +98,12 @@ export async function downloadFromUrl(url: string, fallbackFileName: string): Pr
|
||||
return
|
||||
}
|
||||
|
||||
// Announce the wait before the request goes out, so a slow first byte is not silent either.
|
||||
onProgress?.({ loaded: 0, total: null })
|
||||
|
||||
const response = await api(url)
|
||||
if (!response.ok) throw new Error(`Download failed with status ${response.status}`)
|
||||
const blob = await response.blob()
|
||||
triggerBlobDownload(blob, fileNameFromContentDisposition(response.headers.get('Content-Disposition'), fallbackFileName))
|
||||
const filename = fileNameFromContentDisposition(response.headers.get('Content-Disposition'), fallbackFileName)
|
||||
const blob = onProgress ? await readBlobWithProgress(response, onProgress) : await response.blob()
|
||||
triggerBlobDownload(blob, filename)
|
||||
}
|
||||
|
||||
@@ -3991,7 +3991,13 @@
|
||||
"action": "Download",
|
||||
"audiobookZip": "Audiobook (ZIP)",
|
||||
"allFormatsZip": "All formats (ZIP)",
|
||||
"primaryOnlyZip": "Primary only (ZIP)"
|
||||
"primaryOnlyZip": "Primary only (ZIP)",
|
||||
"preparing": "Preparing download...",
|
||||
"preparingExport": "Preparing {count, plural, one {# book} other {# books}} for download...",
|
||||
"progressPercent": "Downloading {percent} of {total}",
|
||||
"progressBytes": "Downloading {transferred}",
|
||||
"failed": "Download failed",
|
||||
"exportFailed": "Export failed"
|
||||
},
|
||||
"sort": {
|
||||
"moveUp": "Move up",
|
||||
|
||||
Reference in New Issue
Block a user