feat: add epub streaming with server-side file serving

This commit is contained in:
neon
2026-03-04 04:53:52 -06:00
parent 94a0d51584
commit 38a601675e
15 changed files with 471 additions and 61 deletions
-1
View File
@@ -871,7 +871,6 @@ class Loader {
const directUrl = this.#getDirectUrl(path)
if (directUrl) {
el.setAttribute(attr, directUrl)
el.setAttribute('loading', 'lazy') // Native lazy loading hint
return true
}
return false
@@ -35,24 +35,6 @@ export const makeStreamingLoader = (bookId, baseUrl, bookInfo, authToken = null,
}
}
// Build URL with token for browser-initiated requests (fonts, images in CSS)
const getDirectFileUrl = (name) => {
if (!name) return null
const encodedPath = name.split('/').map(encodeURIComponent).join('/')
let url = `${baseUrl}/${bookId}/file/${encodedPath}`
const params = []
if (bookType) {
params.push(`bookType=${encodeURIComponent(bookType)}`)
}
if (authToken) {
params.push(`token=${encodeURIComponent(authToken)}`)
}
if (params.length > 0) {
url += '?' + params.join('&')
}
return url
}
/**
* Load file as text
*/
@@ -105,24 +87,13 @@ export const makeStreamingLoader = (bookId, baseUrl, bookInfo, authToken = null,
return item?.size ?? 0
}
/**
* Get direct URL for a file without fetching it.
* This allows the browser to fetch resources on-demand (fonts, images).
* Note: Uses token in URL since browser-initiated requests can't use headers.
* @param {string} name - File path within the EPUB
* @returns {string|null} Direct URL to the file
*/
const getDirectUrl = (name) => {
if (!name) return null
return getDirectFileUrl(name)
}
return {
loadText,
loadBlob,
getSize,
getDirectUrl, // For lazy loading of fonts/images in CSS
// Expose for debugging
// getDirectUrl intentionally omitted: browser-initiated fetches from inside a
// sandboxed blob-URL iframe are blocked by Cross-Origin-Resource-Policy headers.
// Images go through loadBlob (JS fetch with Authorization header) instead.
_bookInfo: bookInfo,
_manifestMap: manifestMap,
}
+1 -1
View File
@@ -145,7 +145,7 @@ onMounted(async () => {
}
}
await open(fileId, fileFormat, progress.cfi.value)
await open(bookId, fileId, fileFormat, progress.cfi.value)
setChapters(getChapters())
sectionFractions.value = getSectionFractions()
await bookmarks.load(bookId)
@@ -1,5 +1,5 @@
import { onUnmounted, ref } from 'vue'
import { api } from '@/lib/api'
import { api, getAccessToken } from '@/lib/api'
import { useFoliateAnnotations } from './useFoliateAnnotations'
import { useFoliateSelection } from './useFoliateSelection'
import { useFoliateInput } from './useFoliateInput'
@@ -38,10 +38,11 @@ export function useFoliate(
async function loadScript() {
if (customElements.get('foliate-view')) return
const src = import.meta.env.DEV ? `/assets/foliate/view.js?v=${Date.now()}` : '/assets/foliate/view.js'
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.type = 'module'
script.src = '/assets/foliate/view.js'
script.src = src
script.onload = () => setTimeout(resolve, 100)
script.onerror = () => reject(new Error('Failed to load foliate/view.js'))
document.head.appendChild(script)
@@ -49,7 +50,7 @@ export function useFoliate(
await customElements.whenDefined('foliate-view')
}
async function open(fileId: number, format: string, cfi?: string | null) {
async function open(bookId: number, fileId: number, format: string, cfi?: string | null) {
const el = container()
if (!el) return
@@ -104,13 +105,25 @@ export function useFoliate(
loading.value = false
})
const mimeType = format === 'pdf' ? 'application/pdf' : format === 'cbz' ? 'application/zip' : 'application/epub+zip'
const ext = format === 'pdf' ? 'pdf' : format === 'cbz' ? 'cbz' : 'epub'
const res = await api(`/api/v1/books/files/${fileId}/serve`)
if (!res.ok) throw new Error(`Failed to fetch book file: ${res.status}`)
const blob = await res.blob()
const file = new File([blob], `book-file-${fileId}.${ext}`, { type: mimeType })
await view.open(file)
if (format === 'epub') {
const infoRes = await api(`/api/v1/epub/${bookId}/info`)
if (!infoRes.ok) throw new Error(`Failed to fetch EPUB info: ${infoRes.status}`)
const bookInfo = await infoRes.json()
const makeStreamingBook = (window as Record<string, unknown>).makeStreamingBook as
| ((id: number, base: string, info: unknown, token: string | null) => Promise<unknown>)
| undefined
if (!makeStreamingBook) throw new Error('makeStreamingBook not available')
const book = await makeStreamingBook(bookId, '/api/v1/epub', bookInfo, getAccessToken())
await view.open(book as never)
} else {
const mimeType = format === 'pdf' ? 'application/pdf' : 'application/zip'
const ext = format === 'pdf' ? 'pdf' : format === 'cbz' ? 'cbz' : format
const res = await api(`/api/v1/books/files/${fileId}/serve`)
if (!res.ok) throw new Error(`Failed to fetch book file: ${res.status}`)
const blob = await res.blob()
const file = new File([blob], `book-file-${fileId}.${ext}`, { type: mimeType })
await view.open(file)
}
if (onApplyStyles) onApplyStyles(view.renderer)
await view.goTo(cfi ?? 0).catch(() => {})
} catch (e) {
@@ -146,7 +159,7 @@ export function useFoliate(
error,
fraction,
view: viewRef,
open,
open: (bookId: number, fileId: number, format: string, cfi?: string | null) => open(bookId, fileId, format, cfi),
prev: () => getViewEl()?.prev?.(),
next: () => getViewEl()?.next?.(),
goTo: (t: string | number) => getViewEl()?.goTo?.(t),