diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index e3e7c93..8f3206b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,11 +6,12 @@ import { BooksModule } from "./books/books.module.js"; import { DatabaseModule } from "./database/database.module.js"; import { ProgressModule } from "./progress/progress.module.js"; import { ReaderModule } from "./reader/reader.module.js"; +import { ReadingModule } from "./reading/reading.module.js"; import { ScannerModule } from "./scanner/scanner.module.js"; import { HealthController } from "./health.controller.js"; @Module({ - imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule], + imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ReadingModule, ScannerModule, AutomationModule], controllers: [HealthController] }) export class AppModule {} diff --git a/apps/api/src/reading/adapters/archive-reading-source.adapter.test.ts b/apps/api/src/reading/adapters/archive-reading-source.adapter.test.ts new file mode 100644 index 0000000..7619978 --- /dev/null +++ b/apps/api/src/reading/adapters/archive-reading-source.adapter.test.ts @@ -0,0 +1,75 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import AdmZip from "adm-zip"; +import { afterEach, describe, expect, it } from "vitest"; +import { ArchiveReadingSourceAdapter } from "./archive-reading-source.adapter.js"; + +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +describe("ArchiveReadingSourceAdapter", () => { + it("builds an ordered EPUB spine and serves only opaque resource ids", async () => { + const directory = mkdtempSync(join(tmpdir(), "readabook-reading-")); + directories.push(directory); + const filePath = join(directory, "book.epub"); + const zip = new AdmZip(); + zip.addFile("META-INF/container.xml", Buffer.from(``)); + zip.addFile("OPS/package.opf", Buffer.from(``)); + zip.addFile("OPS/chapter-1.xhtml", Buffer.from('

First

')); + zip.addFile("OPS/chapter-2.xhtml", Buffer.from("

Second

")); + zip.addFile("OPS/images/cover.jpg", Buffer.from([1, 2, 3])); + zip.addFile("OPS/styles/book.css", Buffer.from("body { color: black; }")); + zip.writeZip(filePath); + const stat = await import("node:fs").then(({ statSync }) => statSync(filePath)); + const source = new ArchiveReadingSourceAdapter(); + const book = { id: 7, format: "epub", filePath, fileSize: stat.size, fileMtime: stat.mtime.toISOString() }; + + const manifest = await source.manifest(book); + + expect(manifest.spine).toHaveLength(2); + expect(manifest).toMatchObject({ + containerPath: "META-INF/container.xml", + opfPath: "OPS/package.opf", + basePath: "OPS/", + revision: expect.any(String) + }); + expect(JSON.stringify(manifest)).not.toContain(filePath); + expect(JSON.stringify(manifest)).not.toContain(`/books/${book.id}/file`); + expect(manifest.resources).toHaveLength(6); + expect(manifest.resources.map((resource) => resource.resourceId)).not.toContain("OPS/chapter-1.xhtml"); + expect(manifest.resources).toEqual(expect.arrayContaining([ + expect.objectContaining({ href: "META-INF/container.xml", mediaType: "application/xml", size: expect.any(Number), url: expect.stringContaining("/reading-resources/") }), + expect.objectContaining({ href: "OPS/package.opf", mediaType: "application/oebps-package+xml" }), + expect.objectContaining({ href: "OPS/images/cover.jpg", mediaType: "image/jpeg", size: 3 }) + ])); + expect(manifest.spine[0]).toMatchObject({ index: 0, href: "OPS/chapter-1.xhtml", mediaType: "application/xhtml+xml", size: expect.any(Number) }); + const firstResource = manifest.resources.find((resource) => resource.href === "OPS/chapter-1.xhtml"); + const coverResource = manifest.resources.find((resource) => resource.href === "OPS/images/cover.jpg"); + expect(firstResource?.references).toContain(coverResource?.resourceId); + const chapter = await source.resource(book, manifest.spine[0].resourceId); + expect(chapter.contentType).toBe("application/xhtml+xml"); + expect(chapter.data?.toString()).toContain("

First

"); + const container = manifest.resources.find((resource) => resource.href === manifest.containerPath); + expect((await source.resource(book, container!.resourceId)).data?.toString()).toContain("container"); + }); + + it("exposes a PDF as a range-capable original without leaking its path in the manifest", async () => { + const directory = mkdtempSync(join(tmpdir(), "readabook-reading-")); + directories.push(directory); + const filePath = join(directory, "secret-name.pdf"); + writeFileSync(filePath, "%PDF-test"); + const source = new ArchiveReadingSourceAdapter(); + const book = { id: 8, format: "pdf", filePath, fileSize: 9, fileMtime: "2026-01-01T00:00:00.000Z" }; + + const manifest = await source.manifest(book); + const resource = await source.resource(book, "original"); + + expect(JSON.stringify(manifest)).not.toContain(filePath); + expect(manifest).toMatchObject({ format: "pdf", spine: [expect.objectContaining({ resourceId: "original" })], capabilities: { byteRanges: true, lightweightPages: false } }); + expect(resource).toMatchObject({ filePath, contentType: "application/pdf", supportsRanges: true }); + }); +}); diff --git a/apps/api/src/reading/adapters/archive-reading-source.adapter.ts b/apps/api/src/reading/adapters/archive-reading-source.adapter.ts new file mode 100644 index 0000000..f041fd5 --- /dev/null +++ b/apps/api/src/reading/adapters/archive-reading-source.adapter.ts @@ -0,0 +1,220 @@ +import { createHash } from "node:crypto"; +import { existsSync, statSync } from "node:fs"; +import { extname, posix } from "node:path"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import AdmZip from "adm-zip"; +import { XMLParser } from "fast-xml-parser"; +import { lookup } from "mime-types"; +import { ReadingSourcePort } from "../reading.ports.js"; +import { ReadingBook, ReadingManifest, ReadingResource, ReadingResourcePayload, ReadingSpineItem } from "../reading.types.js"; + +const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" }); +const MAX_EPUB_ENTRIES = 20_000; +const MAX_RESOURCE_BYTES = 64 * 1024 * 1024; + +@Injectable() +export class ArchiveReadingSourceAdapter implements ReadingSourcePort { + private readonly epubManifests = new Map(); + + async manifest(book: ReadingBook): Promise { + this.assertReadable(book); + if (book.format === "pdf") return pdfManifest(book); + if (book.format !== "epub") throw new BadRequestException("Adaptive reading supports EPUB and PDF books"); + const revision = revisionFor(book); + const cached = this.epubManifests.get(book.id); + if (cached?.revision === revision) return cached.manifest; + const manifest = epubManifest(book); + this.epubManifests.set(book.id, { revision, manifest }); + return manifest; + } + + async resource(book: ReadingBook, resourceId: string): Promise { + this.assertReadable(book); + if (book.format === "pdf") { + if (resourceId !== "original") throw new NotFoundException("Reading resource not found"); + const size = statSync(book.filePath).size; + return { filePath: book.filePath, contentType: "application/pdf", size, etag: etag(book, resourceId), supportsRanges: true }; + } + if (book.format !== "epub") throw new BadRequestException("Adaptive reading supports EPUB and PDF books"); + const manifest = await this.manifest(book); + const resource = manifest.resources.find((candidate) => candidate.resourceId === resourceId); + if (!resource) throw new NotFoundException("Reading resource not found"); + const zip = new AdmZip(book.filePath); + const entry = zip.getEntry(resource.href); + if (!entry || entry.isDirectory) throw new NotFoundException("Reading resource not found"); + if (entry.header.size > MAX_RESOURCE_BYTES) throw new BadRequestException("EPUB resource is too large to extract"); + const data = entry.getData(); + return { data, contentType: resource.mediaType, size: data.length, etag: etag(book, resourceId), supportsRanges: false }; + } + + private assertReadable(book: ReadingBook): void { + if (!existsSync(book.filePath)) throw new NotFoundException("Book file not found on disk"); + } +} + +function epubManifest(book: ReadingBook): ReadingManifest { + const zip = new AdmZip(book.filePath); + const entries = zip.getEntries(); + if (entries.length > MAX_EPUB_ENTRIES) throw new BadRequestException("EPUB archive has too many entries"); + const containerPath = "META-INF/container.xml"; + const container = xml.parse(zip.readAsText(containerPath)); + const rootfiles = array(container?.container?.rootfiles?.rootfile); + const opfPath = rootfiles[0]?.["@_full-path"]; + if (typeof opfPath !== "string") throw new BadRequestException("EPUB package document is missing"); + const normalizedOpf = normalizeArchivePath(opfPath); + const parsed = xml.parse(zip.readAsText(normalizedOpf)); + const packageDocument = parsed?.package; + const directory = posix.dirname(normalizedOpf); + const basePath = directory === "." ? "" : `${directory}/`; + const items = array(packageDocument?.manifest?.item); + const spineItems = array(packageDocument?.spine?.itemref); + const declaredMediaTypes = new Map(); + const packageIds = new Map(); + for (const item of items) { + const packageId = stringAttribute(item, "@_id"); + const href = stringAttribute(item, "@_href"); + if (!href) continue; + const archivePath = normalizeArchivePath(posix.join(directory, decodeHref(href))); + const mediaType = stringAttribute(item, "@_media-type"); + if (mediaType) declaredMediaTypes.set(archivePath, mediaType); + if (packageId) packageIds.set(packageId, archivePath); + } + const byPackageId = new Map(); + const resources = entries.flatMap((entry): ReadingResource[] => { + if (entry.isDirectory) return []; + const archivePath = normalizeArchivePath(entry.entryName); + const mediaType = declaredMediaTypes.get(archivePath) ?? archiveMediaType(archivePath, normalizedOpf); + const resource: ReadingResource = { + resourceId: opaqueId(archivePath), + href: archivePath, + url: resourceUrl(book.id, opaqueId(archivePath)), + kind: resourceKind(mediaType), + mediaType, + size: entry.header.size, + state: "ready" + }; + return [resource]; + }); + const byHref = new Map(resources.map((resource) => [resource.href, resource])); + for (const [packageId, archivePath] of packageIds) { + const resource = byHref.get(archivePath); + if (resource) byPackageId.set(packageId, resource); + } + addResourceReferences(zip, resources, byHref); + const spine = spineItems.flatMap((item, index): ReadingSpineItem[] => { + if (stringAttribute(item, "@_linear") === "no") return []; + const resource = byPackageId.get(stringAttribute(item, "@_idref") ?? ""); + if (!resource) return []; + return [{ index, href: resource.href, resourceId: resource.resourceId, url: resource.url, mediaType: resource.mediaType, size: resource.size }]; + }); + const revision = revisionFor(book); + return { + bookId: book.id, + format: "epub", + revision, + version: revision, + containerPath, + opfPath: normalizedOpf, + basePath, + resources, + spine, + capabilities: { byteRanges: false, derivedImages: true, lightweightPages: false } + }; +} + +function pdfManifest(book: ReadingBook): ReadingManifest { + const revision = revisionFor(book); + return { + bookId: book.id, + format: "pdf", + revision, + version: revision, + containerPath: null, + opfPath: null, + basePath: null, + resources: [{ resourceId: "original", href: "original", url: resourceUrl(book.id, "original"), kind: "document", mediaType: "application/pdf", size: book.fileSize, state: "ready" }], + spine: [{ index: 0, href: "original", resourceId: "original", url: resourceUrl(book.id, "original"), mediaType: "application/pdf", size: book.fileSize }], + capabilities: { byteRanges: true, derivedImages: false, lightweightPages: false } + }; +} + +function normalizeArchivePath(path: string): string { + const normalized = posix.normalize(path.replace(/^\/+/, "")); + if (normalized === ".." || normalized.startsWith("../")) throw new BadRequestException("Invalid EPUB resource path"); + return normalized; +} + +function decodeHref(href: string): string { + try { + return decodeURIComponent(href.split("#", 1)[0]); + } catch { + throw new BadRequestException("Invalid EPUB resource path"); + } +} + +function opaqueId(path: string): string { + return createHash("sha256").update(path).digest("base64url").slice(0, 22); +} + +function revisionFor(book: ReadingBook): string { + return createHash("sha256").update(`${book.fileSize}:${book.fileMtime}`).digest("base64url"); +} + +function resourceUrl(bookId: number, resourceId: string): string { + return `/books/${bookId}/reading-resources/${encodeURIComponent(resourceId)}`; +} + +function archiveMediaType(path: string, opfPath: string): string { + if (path === "META-INF/container.xml") return "application/xml"; + if (path === opfPath) return "application/oebps-package+xml"; + if (path === "mimetype") return "text/plain"; + return String(lookup(extname(path)) || "application/octet-stream"); +} + +function addResourceReferences(zip: AdmZip, resources: ReadingResource[], byHref: Map): void { + for (const resource of resources) { + if ((resource.kind !== "chapter" && resource.kind !== "stylesheet") || resource.size > 2 * 1024 * 1024) continue; + const entry = zip.getEntry(resource.href); + if (!entry) continue; + const text = entry.getData().toString("utf8"); + const candidates = resource.kind === "stylesheet" + ? [...text.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/gi)].map((match) => match[1]) + : [...text.matchAll(/(?:src|href)\s*=\s*["']([^"']+)["']/gi)].map((match) => match[1]); + const references = candidates.flatMap((candidate): string[] => { + const href = resolveReference(resource.href, candidate); + const target = href ? byHref.get(href) : undefined; + return target && target.kind !== "chapter" ? [target.resourceId] : []; + }); + resource.references = [...new Set(references)]; + } +} + +function resolveReference(parent: string, reference: string): string | null { + const clean = reference.trim(); + if (!clean || clean.startsWith("#") || /^(?:data|https?|mailto|tel):/i.test(clean) || clean.startsWith("//")) return null; + try { + return normalizeArchivePath(posix.join(posix.dirname(parent), decodeHref(clean))); + } catch { + return null; + } +} + +function etag(book: ReadingBook, resourceId: string): string { + return `\"${createHash("sha256").update(`${book.id}:${book.fileSize}:${book.fileMtime}:${resourceId}`).digest("base64url")}\"`; +} + +function array(value: any): any[] { + return Array.isArray(value) ? value : value ? [value] : []; +} + +function stringAttribute(value: any, key: string): string | null { + return typeof value?.[key] === "string" ? value[key] : null; +} + +function resourceKind(mediaType: string): ReadingResource["kind"] { + if (mediaType.startsWith("image/")) return "image"; + if (mediaType === "text/css") return "stylesheet"; + if (mediaType.includes("font") || mediaType.includes("woff")) return "font"; + if (mediaType.includes("html") || mediaType.includes("xml")) return "chapter"; + return "document"; +} diff --git a/apps/api/src/reading/adapters/filesystem-reading-cache.adapter.ts b/apps/api/src/reading/adapters/filesystem-reading-cache.adapter.ts new file mode 100644 index 0000000..5fc8404 --- /dev/null +++ b/apps/api/src/reading/adapters/filesystem-reading-cache.adapter.ts @@ -0,0 +1,37 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Injectable } from "@nestjs/common"; +import { DatabaseService } from "../../database/database.service.js"; +import { ReadingCachePort } from "../reading.ports.js"; +import { ReadingBook } from "../reading.types.js"; + +@Injectable() +export class FilesystemReadingCacheAdapter implements ReadingCachePort { + constructor(private readonly database: DatabaseService) {} + + async get(book: ReadingBook, resourceId: string): Promise { + try { + return await readFile(this.path(book, resourceId)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + } + + async put(book: ReadingBook, resourceId: string, data: Buffer): Promise { + const path = this.path(book, resourceId); + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, data); + } + + async remove(book: ReadingBook, resourceId: string): Promise { + await rm(this.path(book, resourceId), { force: true }); + } + + private path(book: ReadingBook, resourceId: string): string { + const version = createHash("sha256").update(`${book.fileSize}:${book.fileMtime}`).digest("hex").slice(0, 16); + const resource = createHash("sha256").update(resourceId).digest("hex"); + return join(this.database.config.storageDir, "reading-cache", String(book.id), version, resource); + } +} diff --git a/apps/api/src/reading/reading.controller.test.ts b/apps/api/src/reading/reading.controller.test.ts new file mode 100644 index 0000000..77cacf9 --- /dev/null +++ b/apps/api/src/reading/reading.controller.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { ReadingController } from "./reading.controller.js"; + +describe("ReadingController", () => { + it("returns 304 for a matching ETag without a range request", async () => { + const controller = new ReadingController({ + resource: vi.fn().mockResolvedValue(resourcePayload({ partial: false })) + } as any); + const reply = fakeReply(); + + await controller.resource("12", "chapter-1", undefined, '"etag"', reply as any); + + expect(reply.statusCode).toBe(304); + expect(reply.sent).toBeUndefined(); + expect(reply.headers).toEqual({}); + }); + + it("sets cache, ETag and range headers for partial PDF resources", async () => { + const controller = new ReadingController({ + resource: vi.fn().mockResolvedValue(resourcePayload({ partial: true, supportsRanges: true, start: 2, end: 5, size: 10, data: Buffer.from("2345") })) + } as any); + const reply = fakeReply(); + + await controller.resource("12", "original", "bytes=2-5", undefined, reply as any); + + expect(reply.statusCode).toBe(206); + expect(reply.headers).toMatchObject({ + "Accept-Ranges": "bytes", + "Cache-Control": "private, max-age=3600, must-revalidate", + "Content-Length": "4", + "Content-Range": "bytes 2-5/10", + "Content-Type": "application/pdf", + ETag: '"etag"', + "X-Content-Type-Options": "nosniff" + }); + expect(reply.sent?.toString()).toBe("2345"); + }); +}); + +function resourcePayload(overrides: Record) { + return { + data: Buffer.from("chapter"), + contentType: "application/pdf", + size: 7, + etag: '"etag"', + supportsRanges: false, + start: 0, + end: 6, + partial: false, + ...overrides + }; +} + +function fakeReply() { + return { + statusCode: 200, + headers: {} as Record, + sent: undefined as unknown, + code(status: number) { + this.statusCode = status; + return this; + }, + header(name: string, value: string) { + this.headers[name] = value; + return this; + }, + send(payload?: unknown) { + this.sent = payload; + return this; + } + }; +} diff --git a/apps/api/src/reading/reading.controller.ts b/apps/api/src/reading/reading.controller.ts new file mode 100644 index 0000000..aa058e3 --- /dev/null +++ b/apps/api/src/reading/reading.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Headers, Param, Post, Res, UseGuards } from "@nestjs/common"; +import { FastifyReply } from "fastify"; +import { AuthGuard } from "../auth/auth.guard.js"; +import { ZodValidationPipe } from "../common/zod-validation.pipe.js"; +import { ReadingWindowDto, ReadingWindowSchema } from "./reading.dto.js"; +import { ReadingService } from "./reading.service.js"; + +@Controller("books/:bookId") +@UseGuards(AuthGuard) +export class ReadingController { + constructor(private readonly reading: ReadingService) {} + + @Get("reading-manifest") + manifest(@Param("bookId") bookId: string) { + return this.reading.manifest(Number(bookId)); + } + + @Get("reading-resources/:resourceId") + async resource( + @Param("bookId") bookId: string, + @Param("resourceId") resourceId: string, + @Headers("range") range: string | undefined, + @Headers("if-none-match") ifNoneMatch: string | undefined, + @Res() reply: FastifyReply + ) { + const resource = await this.reading.resource(Number(bookId), resourceId, range); + if (!range && ifNoneMatch === resource.etag) return reply.code(304).send(); + if (resource.partial) { + reply.code(206); + reply.header("Content-Range", `bytes ${resource.start}-${resource.end}/${resource.size}`); + } + if (resource.supportsRanges) reply.header("Accept-Ranges", "bytes"); + reply.header("Content-Length", String(resource.end - resource.start + 1)); + reply.header("Content-Type", resource.contentType); + reply.header("ETag", resource.etag); + reply.header("Cache-Control", "private, max-age=3600, must-revalidate"); + reply.header("X-Content-Type-Options", "nosniff"); + return reply.send(resource.stream ?? resource.data); + } + + @Post("reading-window") + window(@Param("bookId") bookId: string, @Body(new ZodValidationPipe(ReadingWindowSchema)) input: ReadingWindowDto) { + return this.reading.window(Number(bookId), input); + } + + @Post("reading-resources/:resourceId/retry") + retry(@Param("bookId") bookId: string, @Param("resourceId") resourceId: string) { + return this.reading.retry(Number(bookId), resourceId); + } +} diff --git a/apps/api/src/reading/reading.dto.ts b/apps/api/src/reading/reading.dto.ts new file mode 100644 index 0000000..74b479e --- /dev/null +++ b/apps/api/src/reading/reading.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ReadingWindowSchema = z.object({ + locator: z.string().trim().min(1).max(512), + mode: z.enum(["auto", "speed", "quality"]).default("auto"), + networkHint: z.enum(["offline", "slow", "normal", "fast", "unknown"]).default("unknown") +}); + +export type ReadingWindowDto = z.infer; diff --git a/apps/api/src/reading/reading.module.ts b/apps/api/src/reading/reading.module.ts new file mode 100644 index 0000000..c6e4f69 --- /dev/null +++ b/apps/api/src/reading/reading.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module.js"; +import { DatabaseModule } from "../database/database.module.js"; +import { JobsModule } from "../jobs/jobs.module.js"; +import { ArchiveReadingSourceAdapter } from "./adapters/archive-reading-source.adapter.js"; +import { FilesystemReadingCacheAdapter } from "./adapters/filesystem-reading-cache.adapter.js"; +import { ReadingController } from "./reading.controller.js"; +import { ReadingCachePort, ReadingSourcePort } from "./reading.ports.js"; +import { ReadingService } from "./reading.service.js"; + +@Module({ + imports: [AuthModule, DatabaseModule, JobsModule], + controllers: [ReadingController], + providers: [ + ReadingService, + { provide: ReadingSourcePort, useClass: ArchiveReadingSourceAdapter }, + { provide: ReadingCachePort, useClass: FilesystemReadingCacheAdapter } + ] +}) +export class ReadingModule {} diff --git a/apps/api/src/reading/reading.ports.ts b/apps/api/src/reading/reading.ports.ts new file mode 100644 index 0000000..a5ddf00 --- /dev/null +++ b/apps/api/src/reading/reading.ports.ts @@ -0,0 +1,12 @@ +import { ReadingBook, ReadingManifest, ReadingResourcePayload } from "./reading.types.js"; + +export abstract class ReadingSourcePort { + abstract manifest(book: ReadingBook): Promise; + abstract resource(book: ReadingBook, resourceId: string): Promise; +} + +export abstract class ReadingCachePort { + abstract get(book: ReadingBook, resourceId: string): Promise; + abstract put(book: ReadingBook, resourceId: string, data: Buffer): Promise; + abstract remove(book: ReadingBook, resourceId: string): Promise; +} diff --git a/apps/api/src/reading/reading.service.test.ts b/apps/api/src/reading/reading.service.test.ts new file mode 100644 index 0000000..6f6fde2 --- /dev/null +++ b/apps/api/src/reading/reading.service.test.ts @@ -0,0 +1,169 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { DatabaseService } from "../database/database.service.js"; +import { books, jobs, libraries } from "../database/schema.js"; +import { JobsService } from "../jobs/jobs.service.js"; +import { ReadingCachePort, ReadingSourcePort } from "./reading.ports.js"; +import { ReadingService } from "./reading.service.js"; +import { ReadingBook, ReadingManifest, ReadingResourcePayload } from "./reading.types.js"; + +const previousDatabasePath = process.env.DATABASE_PATH; +const previousStorageDir = process.env.STORAGE_DIR; +const directories: string[] = []; + +afterEach(() => { + process.env.DATABASE_PATH = previousDatabasePath; + process.env.STORAGE_DIR = previousStorageDir; + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +describe("ReadingService", () => { + it.runIf(canLoadBetterSqlite())("rejects books in disabled libraries", async () => { + const { database, bookId } = fixture(false); + const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache()); + await expect(service.manifest(bookId)).rejects.toThrow("accessible library"); + database.onModuleDestroy(); + }); + + it.runIf(canLoadBetterSqlite())("reports a priority window around the current spine item without preparing while offline", async () => { + const { database, bookId } = fixture(true); + const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache()); + + const window = await service.window(bookId, { locator: "chapter-2", mode: "auto", networkHint: "offline" }); + + expect(window.current).toMatchObject({ resourceId: "chapter-2", relation: "current" }); + expect(window.previous).toMatchObject({ resourceId: "chapter-1", relation: "previous" }); + expect(window.next).toMatchObject({ resourceId: "chapter-3", relation: "next" }); + expect(window.resources).toEqual([expect.objectContaining({ resourceId: "chapter-image", relation: "resource" })]); + expect(window.neighbors).toEqual([]); + expect(database.db.select().from((await import("../database/schema.js")).jobs).all()).toHaveLength(0); + database.onModuleDestroy(); + }); + + it.runIf(canLoadBetterSqlite())("queues the current resource before previous and next resources on slow connections", async () => { + const { database, bookId } = fixture(true); + const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache()); + + const window = await service.window(bookId, { locator: "chapter-2", mode: "speed", networkHint: "slow" }); + + expect(window.current).toMatchObject({ resourceId: "chapter-2", relation: "current", state: "pending" }); + expect(window.previous).toMatchObject({ resourceId: "chapter-1", relation: "previous", state: "pending" }); + expect(window.next).toMatchObject({ resourceId: "chapter-3", relation: "next", state: "pending" }); + const [job] = database.db.select().from(jobs).all(); + expect(job).toMatchObject({ type: "prepare-reading-window", status: "queued" }); + expect(JSON.parse(job.detail ?? "{}")).toEqual({ bookId, resourceIds: ["chapter-2", "chapter-image", "chapter-1", "chapter-3"] }); + await flushImmediates(); + database.onModuleDestroy(); + }); + + it.runIf(canLoadBetterSqlite())("serves byte ranges for the original PDF resource", async () => { + const { database, bookId, directory } = fixture(true, "pdf"); + const filePath = join(directory, "book.pdf"); + writeFileSync(filePath, Buffer.from("0123456789")); + database.db.update(books).set({ filePath, fileSize: 10 }).where(eq(books.id, bookId)).run(); + const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache()); + + const resource = await service.resource(bookId, "original", "bytes=2-5"); + + expect(resource).toMatchObject({ + contentType: "application/pdf", + etag: '"pdf-etag"', + supportsRanges: true, + size: 10, + start: 2, + end: 5, + partial: true + }); + await expect(readStream(resource.stream)).resolves.toEqual(Buffer.from("2345")); + database.onModuleDestroy(); + }); +}); + +class FakeSource extends ReadingSourcePort { + async manifest(book: ReadingBook): Promise { + if (book.format === "pdf") { + return { + bookId: book.id, + format: "pdf", + version: "pdf-version", + revision: "pdf-version", + containerPath: null, opfPath: null, basePath: null, + resources: [{ resourceId: "original", href: "original", url: "/original", kind: "document", mediaType: "application/pdf", size: book.fileSize, state: "ready" }], + spine: [{ index: 0, href: "original", resourceId: "original", url: "/original", mediaType: "application/pdf", size: book.fileSize }], + capabilities: { byteRanges: true, derivedImages: false, lightweightPages: false } + }; + } + return { + bookId: book.id, + format: "epub", + revision: "version", + version: "version", + containerPath: "META-INF/container.xml", opfPath: "OPS/package.opf", basePath: "OPS/", + resources: [ + ...["chapter-1", "chapter-2", "chapter-3"].map((id) => ({ resourceId: id, href: id, url: `/${id}`, kind: "chapter" as const, mediaType: "application/xhtml+xml", size: 7, state: "ready" as const, ...(id === "chapter-2" ? { references: ["chapter-image"] } : {}) })), + { resourceId: "chapter-image", href: "image.jpg", url: "/chapter-image", kind: "image" as const, mediaType: "image/jpeg", size: 5, state: "ready" as const } + ], + spine: ["chapter-1", "chapter-2", "chapter-3"].map((id, index) => ({ index, href: id, resourceId: id, url: `/${id}`, mediaType: "application/xhtml+xml", size: 7 })), + capabilities: { byteRanges: false, derivedImages: true, lightweightPages: false } + }; + } + async resource(book: ReadingBook, resourceId: string): Promise { + if (book.format === "pdf" && resourceId === "original") { + return { filePath: book.filePath, contentType: "application/pdf", size: book.fileSize, etag: '"pdf-etag"', supportsRanges: true }; + } + return { data: Buffer.from("chapter"), contentType: "application/xhtml+xml", size: 7, etag: '"etag"', supportsRanges: false }; + } +} + +class MemoryCache extends ReadingCachePort { + private readonly values = new Map(); + async get(_book: ReadingBook, resourceId: string) { return this.values.get(resourceId) ?? null; } + async put(_book: ReadingBook, resourceId: string, data: Buffer) { this.values.set(resourceId, data); } + async remove(_book: ReadingBook, resourceId: string) { this.values.delete(resourceId); } +} + +function fixture(enabled: boolean, format: "epub" | "pdf" = "epub") { + const directory = mkdtempSync(join(tmpdir(), "readabook-reading-service-")); + directories.push(directory); + process.env.DATABASE_PATH = join(directory, "test.sqlite"); + process.env.STORAGE_DIR = join(directory, "storage"); + const database = new DatabaseService(); + const now = database.now(); + const library = database.db.insert(libraries).values({ name: "Library", path: directory, enabled, createdAt: now, updatedAt: now }).returning().get(); + const book = database.db.insert(books).values({ + libraryId: library.id, seriesId: null, title: "Book", author: null, description: null, isbn: null, isbn13: null, + identifiersJson: null, localMetadataJson: null, language: null, publisher: null, publishedDate: null, volumeNumber: null, + volumeLabel: null, format, filePath: join(directory, `book.${format}`), coverPath: null, metadataStatus: "none", + metadataProvenanceJson: null, scanStatus: "succeeded", enrichmentStatus: "idle", fileSize: 10, fileMtime: now, + createdAt: now, updatedAt: now + }).returning().get(); + return { database, bookId: book.id, directory }; +} + +function canLoadBetterSqlite(): boolean { + try { + return Boolean(DatabaseService); + } catch { + return false; + } +} + +function flushImmediates() { + return new Promise((resolve) => setImmediate(resolve)); +} + +function readStream(stream: NodeJS.ReadableStream | undefined) { + return new Promise((resolve, reject) => { + if (!stream) { + resolve(Buffer.alloc(0)); + return; + } + const chunks: Buffer[] = []; + stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + stream.on("error", reject); + stream.on("end", () => resolve(Buffer.concat(chunks))); + }); +} diff --git a/apps/api/src/reading/reading.service.ts b/apps/api/src/reading/reading.service.ts new file mode 100644 index 0000000..2a054ff --- /dev/null +++ b/apps/api/src/reading/reading.service.ts @@ -0,0 +1,168 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { BadRequestException, HttpException, HttpStatus, Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { and, eq } from "drizzle-orm"; +import { DatabaseService } from "../database/database.service.js"; +import { books, libraries } from "../database/schema.js"; +import { JobsService } from "../jobs/jobs.service.js"; +import { ReadingCachePort, ReadingSourcePort } from "./reading.ports.js"; +import { ReadingBook, ReadingResourcePayload, ReadingWindowInput } from "./reading.types.js"; + +@Injectable() +export class ReadingService { + private readonly preparing = new Set(); + + constructor( + private readonly database: DatabaseService, + private readonly jobs: JobsService, + @Inject(ReadingSourcePort) private readonly source: ReadingSourcePort, + @Inject(ReadingCachePort) private readonly cache: ReadingCachePort + ) {} + + async manifest(bookId: number) { + return this.source.manifest(this.accessibleBook(bookId)); + } + + async resource(bookId: number, resourceId: string, range?: string): Promise { + const book = this.accessibleBook(bookId); + const cached = book.format === "epub" ? await this.cache.get(book, resourceId) : null; + const manifestResource = cached ? (await this.source.manifest(book)).resources.find((resource) => resource.resourceId === resourceId) : null; + if (cached && !manifestResource) throw new NotFoundException("Reading resource not found"); + const payload = cached && manifestResource + ? { data: cached, contentType: manifestResource.mediaType, size: cached.length, etag: manifestVersionEtag(book, resourceId), supportsRanges: false } + : await this.source.resource(book, resourceId); + const byteRange = parseByteRange(range, payload.size, payload.supportsRanges); + if (payload.filePath) { + return { ...payload, stream: createReadStream(payload.filePath, { start: byteRange.start, end: byteRange.end }), ...byteRange }; + } + const data = payload.data; + if (!data) throw new NotFoundException("Reading resource not found"); + if (!cached) await this.cache.put(book, resourceId, data); + return { ...payload, data: data.subarray(byteRange.start, byteRange.end + 1), ...byteRange }; + } + + async window(bookId: number, input: ReadingWindowInput) { + const book = this.accessibleBook(bookId); + const manifest = await this.source.manifest(book); + const position = resolvePosition(input.locator, manifest.spine); + const current = manifest.spine[position]; + const referenced = referencedResources(current.resourceId, manifest.resources); + const ordered = priorityWindow(manifest.spine, position, referenced, input.networkHint === "slow" || input.mode === "speed" ? 1 : 2); + const states = await Promise.all(ordered.map(async ({ id, distance }) => ({ + resourceId: id, + relation: distance === 0 ? "current" : distance === null ? "resource" : distance === -1 ? "previous" : distance === 1 ? "next" : "neighbor", + state: await this.resourceState(book, id) + }))); + if (input.networkHint !== "offline") this.enqueuePreparation(book, ordered.map((entry) => entry.id)); + return { + bookId, + locator: input.locator, + mode: input.mode, + networkHint: input.networkHint, + current: states.find((state) => state.relation === "current") ?? null, + previous: states.find((state) => state.relation === "previous") ?? null, + next: states.find((state) => state.relation === "next") ?? null, + resources: states.filter((state) => state.relation === "resource"), + neighbors: states.filter((state) => state.relation === "neighbor") + }; + } + + async retry(bookId: number, resourceId: string) { + const book = this.accessibleBook(bookId); + const manifest = await this.source.manifest(book); + if (!manifest.resources.some((resource) => resource.resourceId === resourceId)) throw new NotFoundException("Reading resource not found"); + await this.cache.remove(book, resourceId); + this.enqueuePreparation(book, [resourceId], true); + return { bookId, resourceId, state: "pending" as const }; + } + + private accessibleBook(bookId: number): ReadingBook { + if (!Number.isInteger(bookId) || bookId < 1) throw new BadRequestException("Invalid book id"); + const row = this.database.db + .select({ id: books.id, format: books.format, filePath: books.filePath, fileSize: books.fileSize, fileMtime: books.fileMtime }) + .from(books) + .innerJoin(libraries, and(eq(libraries.id, books.libraryId), eq(libraries.enabled, true))) + .where(eq(books.id, bookId)) + .get(); + if (!row) throw new NotFoundException("Book not found in an accessible library"); + return row; + } + + private async resourceState(book: ReadingBook, resourceId: string) { + if (book.format === "pdf" && resourceId === "original") return "ready" as const; + return (await this.cache.get(book, resourceId)) ? "ready" as const : "pending" as const; + } + + private enqueuePreparation(book: ReadingBook, resourceIds: string[], force = false): void { + const pending = resourceIds.filter((id) => force || !this.preparing.has(`${book.id}:${id}`)); + if (!pending.length) return; + const job = this.jobs.create("prepare-reading-window", JSON.stringify({ bookId: book.id, resourceIds: pending })); + for (const id of pending) this.preparing.add(`${book.id}:${id}`); + setImmediate(async () => { + this.jobs.markRunning(job.id, JSON.stringify({ bookId: book.id, resourceIds: pending })); + try { + for (const resourceId of pending) { + const payload = await this.source.resource(book, resourceId); + if (payload.data) await this.cache.put(book, resourceId, payload.data); + } + this.jobs.markSucceeded(job.id, JSON.stringify({ bookId: book.id, prepared: pending })); + } catch (error) { + this.jobs.markFailed(job.id, error); + } finally { + for (const id of pending) this.preparing.delete(`${book.id}:${id}`); + } + }); + } +} + +function manifestVersionEtag(book: ReadingBook, resourceId: string): string { + return `\"${createHash("sha256").update(`${book.id}:${book.fileSize}:${book.fileMtime}:${resourceId}`).digest("base64url")}\"`; +} + +function resolvePosition(locator: string, spine: Array<{ index: number; href: string; resourceId: string }>): number { + if (!spine.length) throw new BadRequestException("Book has no readable spine"); + const direct = spine.findIndex((item) => item.resourceId === locator || item.href === locator); + if (direct >= 0) return direct; + const numeric = Number(locator.match(/(?:^|:)(\d+)(?:$|:)/)?.[1]); + if (Number.isInteger(numeric) && numeric >= 0 && numeric < spine.length) return numeric; + throw new BadRequestException("Locator does not identify a readable resource"); +} + +function priorityWindow(spine: Array<{ resourceId: string }>, position: number, referenced: string[], radius: number) { + const result: Array<{ id: string; distance: number | null }> = [{ id: spine[position].resourceId, distance: 0 }]; + for (const resourceId of referenced) result.push({ id: resourceId, distance: null }); + for (let distance = 1; distance <= radius; distance += 1) { + if (position - distance >= 0) result.push({ id: spine[position - distance].resourceId, distance: -distance }); + if (position + distance < spine.length) result.push({ id: spine[position + distance].resourceId, distance }); + } + return result.filter((entry, index) => result.findIndex((candidate) => candidate.id === entry.id) === index); +} + +function referencedResources(currentId: string, resources: Array<{ resourceId: string; references?: string[] }>): string[] { + const byId = new Map(resources.map((resource) => [resource.resourceId, resource])); + const result: string[] = []; + const queue = [...(byId.get(currentId)?.references ?? [])]; + const seen = new Set([currentId]); + while (queue.length) { + const resourceId = queue.shift()!; + if (seen.has(resourceId)) continue; + seen.add(resourceId); + result.push(resourceId); + queue.push(...(byId.get(resourceId)?.references ?? [])); + } + return result; +} + +function parseByteRange(range: string | undefined, size: number, supported: boolean) { + if (!range) return { start: 0, end: Math.max(size - 1, 0), partial: false }; + if (!supported || size <= 0) throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE); + const match = range.match(/^bytes=(\d*)-(\d*)$/); + if (!match) throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE); + const suffix = !match[1] && match[2] ? Number(match[2]) : null; + const start = suffix === null ? Number(match[1]) : Math.max(size - suffix, 0); + const end = suffix === null && match[2] ? Number(match[2]) : size - 1; + if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) { + throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE); + } + return { start, end: Math.min(end, size - 1), partial: true }; +} diff --git a/apps/api/src/reading/reading.types.ts b/apps/api/src/reading/reading.types.ts new file mode 100644 index 0000000..1f539f4 --- /dev/null +++ b/apps/api/src/reading/reading.types.ts @@ -0,0 +1,63 @@ +export type ReadingFormat = "epub" | "pdf"; +export type ReadingResourceKind = "chapter" | "image" | "stylesheet" | "font" | "document"; +export type ReadingResourceState = "ready" | "pending" | "unsupported" | "failed"; + +export type ReadingResource = { + resourceId: string; + href: string; + url: string; + kind: ReadingResourceKind; + mediaType: string; + size: number; + state: ReadingResourceState; + references?: string[]; +}; + +export type ReadingSpineItem = { + index: number; + href: string; + resourceId: string; + url: string; + mediaType: string; + size: number; +}; + +export type ReadingManifest = { + bookId: number; + format: ReadingFormat; + revision: string; + version: string; + containerPath: string | null; + opfPath: string | null; + basePath: string | null; + resources: ReadingResource[]; + spine: ReadingSpineItem[]; + capabilities: { + byteRanges: boolean; + derivedImages: boolean; + lightweightPages: boolean; + }; +}; + +export type ReadingBook = { + id: number; + format: string; + filePath: string; + fileSize: number; + fileMtime: string; +}; + +export type ReadingResourcePayload = { + data?: Buffer; + filePath?: string; + contentType: string; + size: number; + etag: string; + supportsRanges: boolean; +}; + +export type ReadingWindowInput = { + locator: string; + mode: "auto" | "speed" | "quality"; + networkHint: "offline" | "slow" | "normal" | "fast" | "unknown"; +}; diff --git a/apps/web/src/api/client.test.ts b/apps/web/src/api/client.test.ts index 2811fbc..5e96723 100644 --- a/apps/web/src/api/client.test.ts +++ b/apps/web/src/api/client.test.ts @@ -59,6 +59,27 @@ describe("api fallback helpers", () => { expect(headers.has("Content-Type")).toBe(false); }); + it("sends the adaptive reading window with the selected network policy", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ bookId: 12, locator: "chapter-2", current: null, previous: null, next: null, neighbors: [] }), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + vi.stubGlobal("fetch", fetchMock); + + await api.readingWindow(12, "chapter-2", "speed", "slow"); + + expect(fetchMock.mock.calls[0][0]).toBe("/books/12/reading-window"); + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ locator: "chapter-2", mode: "speed", networkHint: "slow" }); + }); + + it("builds an encoded reading resource URL", () => { + expect(api.readingResourceUrl(12, "chapter/one")).toBe("/books/12/reading-resources/chapter%2Fone"); + }); + it("surfaces create library API errors without fallback", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ message: "Library path does not exist" }), { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index cddc7d7..5dac796 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -27,7 +27,16 @@ import { mockProgress, mockUser } from "./mockData"; -import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types"; +import type { + CbzPagesDto, + ContinueItem, + ReaderPreferencesDto, + ReadingConnectionMode, + ReadingManifestDto, + ReadingNetworkHint, + ReadingWindowDto, + Session +} from "./types"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:"; @@ -179,6 +188,21 @@ export const api = { bookCoverUrl(id: number): string { return `${API_BASE}/books/${id}/cover`; }, + async readingManifest(id: number): Promise { + return request(`/books/${id}/reading-manifest`); + }, + readingResourceUrl(id: number, resourceId: string): string { + return `${API_BASE}/books/${id}/reading-resources/${encodeURIComponent(resourceId)}`; + }, + async readingWindow(id: number, locator: string, mode: ReadingConnectionMode, networkHint: ReadingNetworkHint): Promise { + return request(`/books/${id}/reading-window`, { + method: "POST", + body: JSON.stringify({ locator, mode, networkHint }) + }); + }, + async retryReadingResource(id: number, resourceId: string): Promise { + await request(`/books/${id}/reading-resources/${encodeURIComponent(resourceId)}/retry`, { method: "POST" }); + }, async cbzPages(id: number): Promise { return request(`/books/${id}/pages`); }, diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index af8a676..fc30a89 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -32,6 +32,63 @@ export type ReaderMode = "horizontal" | "vertical"; export type ReaderFit = "page" | "width"; +export type ReadingConnectionMode = "auto" | "speed" | "quality"; +export type ReadingNetworkHint = "offline" | "slow" | "normal" | "fast" | "unknown"; + +export type ReadingResourceDto = { + resourceId: string; + href: string; + url: string; + kind: "chapter" | "image" | "stylesheet" | "font" | "document"; + mediaType: string; + size: number; + state: "ready" | "pending" | "unsupported" | "failed"; + references?: string[]; +}; + +export type ReadingSpineItemDto = { + index: number; + href: string; + resourceId: string; + url: string; + mediaType: string; + size: number; +}; + +export type ReadingManifestDto = { + bookId: number; + format: "epub" | "pdf"; + revision: string; + version?: string; + containerPath: string | null; + opfPath: string | null; + basePath: string | null; + resources: ReadingResourceDto[]; + spine: ReadingSpineItemDto[]; + capabilities: { + byteRanges: boolean; + derivedImages: boolean; + lightweightPages: boolean; + }; +}; + +export type ReadingWindowItemDto = { + resourceId: string; + relation: "current" | "previous" | "next" | "neighbor"; + state: ReadingResourceDto["state"]; +}; + +export type ReadingWindowDto = { + bookId: number; + locator: string; + mode: ReadingConnectionMode; + networkHint: ReadingNetworkHint; + current: ReadingWindowItemDto | null; + previous: ReadingWindowItemDto | null; + next: ReadingWindowItemDto | null; + neighbors: ReadingWindowItemDto[]; +}; + export type ReaderPreferencesDto = { mode: ReaderMode; fit?: ReaderFit; diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index 3b32ebb..05ada56 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -10,6 +10,7 @@ import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reade import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll"; import { useReaderPreferences } from "../reader/useReaderPreferences"; import { useReaderProgress } from "../reader/useReaderProgress"; +import { useAdaptiveReading } from "../reader/useAdaptiveReading"; import { navigate } from "../router"; import { EmptyState, LoadingState, Panel } from "../components/ui"; @@ -69,6 +70,7 @@ export function ReaderPage({ bookId }: { bookId: number }) { const [readerControls, setReaderControls] = useState(idleControls); const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId); const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId); + const adaptive = useAdaptiveReading(bookId, book?.format === "epub" || book?.format === "pdf"); async function loadBook() { setLoading(true); @@ -104,7 +106,11 @@ export function ReaderPage({ bookId }: { bookId: number }) { if (nextPage) setPage((current) => (current === nextPage ? current : nextPage)); }, [progress]); - const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]); + const legacyFileUrl = useMemo(() => (book?.format === "epub" ? "" : api.bookFileUrl(bookId)), [book?.format, bookId]); + const adaptiveFileUrl = useMemo(() => { + if (book?.format !== "pdf" || !adaptive.manifest) return legacyFileUrl; + return adaptive.manifest.spine[0]?.url ?? api.readingResourceUrl(bookId, "original"); + }, [adaptive.manifest, book?.format, bookId, legacyFileUrl]); const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]); const savePdfPage = useCallback( (nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => { @@ -113,10 +119,19 @@ export function ReaderPage({ bookId }: { bookId: number }) { const percent = pdfPagePercent(nextPage, pages, anchor); if (strategy === "queued") queueSave(locator, percent); else void save(locator, percent); + void adaptive.prioritize(adaptive.manifest?.spine[0]?.resourceId ?? "original"); }, - [queueSave, save] + [adaptive.manifest?.spine, adaptive.prioritize, queueSave, save] ); - const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]); + const saveEpubLocator = useCallback((locator: string, percent: number, readingLocator: string) => { + void save(locator, percent); + void adaptive.prioritize(readingLocator); + }, [adaptive.prioritize, save]); + + useEffect(() => { + const first = adaptive.manifest?.spine[0]?.resourceId; + if (first) void adaptive.prioritize(first); + }, [adaptive.manifest?.revision, adaptive.mode]); const saveComicPage = useCallback( (nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => { setPage(nextPage); @@ -129,7 +144,7 @@ export function ReaderPage({ bookId }: { bookId: number }) { [book?.format, queueSave, save] ); - const readerError = error ?? progressError ?? preferencesError; + const readerError = error ?? progressError ?? preferencesError ?? adaptive.error; const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr"; const supportsMode = supportsZoom; const currentVisiblePage = useCallback(() => { @@ -231,10 +246,12 @@ export function ReaderPage({ bookId }: { bookId: number }) { title={book?.title ?? "Ouverture du lecteur"} backHref={backHref} error={readerError} - onRetry={error ? () => void loadBook() : undefined} controls={readerControls} zoomControls={zoomControls} modeControls={modeControls} + connectionControls={book.format === "epub" || book.format === "pdf" ? { mode: adaptive.mode, onModeChange: adaptive.setMode } : undefined} + activity={book.format === "epub" || book.format === "pdf" ? adaptive.statusLabel : undefined} + onRetry={error ? () => void loadBook() : adaptive.error ? adaptive.retry : undefined} > {book.format === "pdf" ? ( - ) : ( + ) : adaptive.manifest?.format === "epub" ? ( + ) : ( +
Ouverture de la page…
)}
diff --git a/apps/web/src/reader/EpubReader.tsx b/apps/web/src/reader/EpubReader.tsx index c764313..a00704f 100644 --- a/apps/web/src/reader/EpubReader.tsx +++ b/apps/web/src/reader/EpubReader.tsx @@ -1,36 +1,60 @@ import { useEffect, useRef, useState } from "react"; +import type { ReadingManifestDto, ReaderMode } from "../api/types"; import { ReaderError, readerErrorMessage } from "./ReaderError"; import type { ReaderControls } from "./ReaderShell"; -import type { ReaderMode } from "../api/types"; - -type FoliateLocation = { - cfi?: string; - fraction?: number; - current?: number; - total?: number; -}; +type FoliateLocation = { cfi?: string; fraction?: number; current?: number; total?: number }; type FoliateView = HTMLElement & { - open(input: File | Blob | string): Promise; + open(input: unknown): Promise; close(): void; goLeft(): Promise; goRight(): Promise; goTo(target: string): Promise; next(): Promise; - lastLocation?: FoliateLocation; +}; +type EpubApiLoader = { + loadText(path: string): Promise; + loadBlob(path: string): Promise; + getSize(path: string): number; }; -export function epubFileName(url: string): string { +function normalizedResourcePath(path: string): string { + const withoutFragment = path.split("#", 1)[0].split("?", 1)[0]; try { - const base = globalThis.location?.href ?? "http://readabook.local/"; - const pathname = new URL(url, base).pathname; - const name = pathname.split("/").filter(Boolean).at(-1); - return name && name.includes(".") ? name : "book.epub"; + return decodeURI(withoutFragment).replace(/^\.\//, "").replace(/^\/+/, ""); } catch { - return "book.epub"; + return withoutFragment.replace(/^\.\//, "").replace(/^\/+/, ""); } } +export function createEpubApiLoader(manifest: ReadingManifestDto, fetcher: typeof fetch = fetch): EpubApiLoader { + const resources = new Map(manifest.resources.map((resource) => [normalizedResourcePath(resource.href), resource])); + const responses = new Map>(); + const loadBlob = async (path: string) => { + const resource = resources.get(normalizedResourcePath(path)); + if (!resource) return null; + let pending = responses.get(resource.resourceId); + if (!pending) { + pending = fetcher(resource.url, { credentials: "include", cache: "default" }).then(async (response) => { + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + return response.blob(); + }); + responses.set(resource.resourceId, pending); + } + try { + return await pending; + } catch (error) { + responses.delete(resource.resourceId); + throw error; + } + }; + return { + loadBlob, + loadText: async (path) => (await loadBlob(path))?.text() ?? null, + getSize: (path) => resources.get(normalizedResourcePath(path))?.size ?? 0 + }; +} + function locationPercent(location: FoliateLocation): number { if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100)); if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) { @@ -39,24 +63,18 @@ function locationPercent(location: FoliateLocation): number { return 1; } -export function EpubReader({ - url, - locator, - backHref, - mode, - onLocatorChange, - onControlsChange -}: { - url: string; +export function EpubReader({ manifest, locator, backHref, mode, onLocatorChange, onControlsChange }: { + manifest: ReadingManifestDto; locator?: string; backHref: string; mode: ReaderMode; - onLocatorChange: (locator: string, percent: number) => void; + onLocatorChange: (locator: string, percent: number, readingLocator: string) => void; onControlsChange: (controls: ReaderControls) => void; }) { const hostRef = useRef(null); const viewRef = useRef(null); const locatorRef = useRef(locator); + const sectionIndexRef = useRef(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [attempt, setAttempt] = useState(0); @@ -65,43 +83,39 @@ export function EpubReader({ onControlsChange({ canPrevious: !loading && !error, canNext: !loading && !error, - positionLabel: loading ? "Ouverture EPUB" : "Lecture integree", + positionLabel: loading ? "Ouverture de la page…" : "Lecture intégrée", onPrevious: () => void viewRef.current?.goLeft(), onNext: () => void viewRef.current?.goRight() }); }, [error, loading, onControlsChange]); - useEffect(() => { - locatorRef.current = locator; - }, [locator]); + useEffect(() => { locatorRef.current = locator; }, [locator]); useEffect(() => { let cancelled = false; let view: FoliateView | null = null; - async function mount() { try { setLoading(true); setError(undefined); - await import("foliate-js/view.js"); + const [{ EPUB }] = await Promise.all([import("foliate-js/epub.js"), import("foliate-js/view.js")]); if (cancelled || !hostRef.current) return; - - const response = await fetch(url, { credentials: "include" }); - if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); - const blob = await response.blob(); + const book = await new EPUB(createEpubApiLoader(manifest)).init(); if (cancelled || !hostRef.current) return; - view = document.createElement("foliate-view") as FoliateView; view.classList.add("epub-view"); + view.addEventListener("load", (event) => { + const index = (event as CustomEvent<{ index?: number }>).detail?.index; + if (typeof index === "number") sectionIndexRef.current = index; + }); view.addEventListener("relocate", (event) => { const location = (event as CustomEvent).detail; - if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location)); + const spine = manifest.spine[sectionIndexRef.current]; + if (location?.cfi && spine) onLocatorChange(location.cfi, locationPercent(location), spine.resourceId); }); hostRef.current.replaceChildren(view); viewRef.current = view; - - const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" }); - await view.open(file); + await view.open(book); if (cancelled) return; if (locatorRef.current) await view.goTo(locatorRef.current); else await view.next(); @@ -113,7 +127,6 @@ export function EpubReader({ } } } - void mount(); return () => { cancelled = true; @@ -121,7 +134,7 @@ export function EpubReader({ view?.remove(); if (viewRef.current === view) viewRef.current = null; }; - }, [attempt, onLocatorChange, url]); + }, [attempt, manifest, onLocatorChange]); useEffect(() => { viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical"); @@ -129,28 +142,10 @@ export function EpubReader({ }, [mode]); if (error) { - return ( -
- setAttempt((value) => value + 1)} - /> -
- ); + return
setAttempt((value) => value + 1)} />
; } - - return ( -
- {loading && ( -
- Ouverture EPUB -
- )} -
-
- ); + return
+ {loading &&
Ouverture de la page…
} +
+
; } diff --git a/apps/web/src/reader/EpubReaderAdaptive.test.ts b/apps/web/src/reader/EpubReaderAdaptive.test.ts new file mode 100644 index 0000000..93e2d8c --- /dev/null +++ b/apps/web/src/reader/EpubReaderAdaptive.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import type { ReadingManifestDto } from "../api/types"; +import { createEpubApiLoader } from "./EpubReader"; + +const manifest: ReadingManifestDto = { + bookId: 4, + format: "epub", + revision: "rev-1", + containerPath: "META-INF/container.xml", + opfPath: "OPS/package.opf", + basePath: "OPS/", + resources: [ + { resourceId: "container", href: "META-INF/container.xml", url: "/books/4/reading-resources/container", mediaType: "application/xml", size: 12, kind: "document", state: "ready" }, + { resourceId: "chapter", href: "OPS/text/chapter.xhtml", url: "/books/4/reading-resources/chapter", mediaType: "application/xhtml+xml", size: 18, kind: "chapter", state: "ready" } + ], + spine: [{ index: 0, href: "OPS/text/chapter.xhtml", resourceId: "chapter", url: "/books/4/reading-resources/chapter", mediaType: "application/xhtml+xml", size: 18 }], + capabilities: { byteRanges: false, derivedImages: true, lightweightPages: false } +}; + +describe("EPUB adaptive loader", () => { + it("loads manifest resources with credentials and reuses a fetched entry", async () => { + const fetcher = vi.fn().mockResolvedValue(new Response("chapter", { status: 200 })); + const loader = createEpubApiLoader(manifest, fetcher); + + await expect(loader.loadText("OPS/text/chapter.xhtml#part")).resolves.toBe("chapter"); + await loader.loadBlob("OPS/text/chapter.xhtml"); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledWith("/books/4/reading-resources/chapter", { credentials: "include", cache: "default" }); + expect(loader.getSize("OPS/text/chapter.xhtml")).toBe(18); + }); + + it("does not contain the complete-book fetch path", () => { + const source = readFileSync(new URL("./EpubReader.tsx", import.meta.url), "utf8"); + expect(source).not.toContain("bookFileUrl"); + expect(source).not.toContain("fetch(url"); + expect(source).not.toContain("new File("); + expect(source).toContain("new EPUB(createEpubApiLoader(manifest)).init()"); + }); +}); diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index edf720e..7af3567 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -285,7 +285,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon onControlsChange({ canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1, canNext: !loadingDocument && !documentError && !pageError && currentPage < pages, - positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages), + positionLabel: loadingDocument ? "Ouverture de la page…" : readerPositionLabel(currentPage, pages), onPrevious: () => go(currentPage - 1), onNext: () => go(currentPage + 1) }); @@ -584,7 +584,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
{loadingDocument || !documentProxy ? (
- Ouverture PDF + Ouverture de la page…
) : mode === "vertical" ? (
@@ -619,7 +619,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
{(!viewportSize || loadingPage || !pageRendered) && (
- Page {currentPage} + Ouverture de la page…
)} diff --git a/apps/web/src/reader/ReaderError.tsx b/apps/web/src/reader/ReaderError.tsx index 7a184de..ad9ad87 100644 --- a/apps/web/src/reader/ReaderError.tsx +++ b/apps/web/src/reader/ReaderError.tsx @@ -18,7 +18,7 @@ export function ReaderError({ title: string; detail: string; technicalDetail?: string; - downloadUrl: string; + downloadUrl?: string; backHref: string; onRetry: () => void; }) { @@ -37,10 +37,12 @@ export function ReaderError({ Retour à la fiche - - - Télécharger - + {downloadUrl && ( + + + Télécharger + + )}
{technicalDetail && (
diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx index 47f023a..f0f87a0 100644 --- a/apps/web/src/reader/ReaderShell.tsx +++ b/apps/web/src/reader/ReaderShell.tsx @@ -14,6 +14,7 @@ import { import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures"; import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout"; import type { ReaderMode } from "./readerScroll"; +import type { ReadingConnectionMode } from "../api/types"; export type ReaderControls = { canPrevious: boolean; @@ -36,6 +37,11 @@ export type ReaderModeControls = { onModeChange: (mode: ReaderMode) => void; }; +export type ReaderConnectionControls = { + mode: ReadingConnectionMode; + onModeChange: (mode: ReadingConnectionMode) => void; +}; + type ReaderShellProps = { title: string; backHref: string; @@ -44,6 +50,8 @@ type ReaderShellProps = { controls: ReaderControls; zoomControls?: ReaderZoomControls; modeControls?: ReaderModeControls; + connectionControls?: ReaderConnectionControls; + activity?: string; children: ReactNode; }; @@ -105,7 +113,7 @@ function scheduleReaderZoomAnchorRestore(stage: HTMLElement, anchor: ReaderZoomA }); } -export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) { +export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, connectionControls, activity, children }: ReaderShellProps) { const readerRef = useRef(null); const headerRef = useRef(null); const stageRef = useRef(null); @@ -368,6 +376,16 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon )} + {connectionControls && ( + + )}
)} + {!error && activity && ( +
+ {activity} +
+ )}
{children}
diff --git a/apps/web/src/reader/ReaderShellAdaptive.test.tsx b/apps/web/src/reader/ReaderShellAdaptive.test.tsx new file mode 100644 index 0000000..0d9e893 --- /dev/null +++ b/apps/web/src/reader/ReaderShellAdaptive.test.tsx @@ -0,0 +1,41 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { ReaderShell } from "./ReaderShell"; + +describe("ReaderShell adaptive connection controls", () => { + it("renders the slow connection mode choices without technical wording or a global percentage", () => { + const html = renderToStaticMarkup( + undefined, onNext: () => undefined }} + connectionControls={{ mode: "auto", onModeChange: () => undefined }} + > +

Page courante

+
+ ); + + expect(html).toContain("Mode connexion lente"); + expect(html).toContain("Automatique"); + expect(html).toContain("Toujours privilégier la vitesse"); + expect(html).toContain("Toujours privilégier la qualité"); + expect(html).not.toMatch(/%|pourcentage global|manifest|spine|range|etag|cache|resource|locator/i); + }); + + it("shows retry next to offline or loading errors", () => { + const html = renderToStaticMarkup( + undefined} + controls={{ canPrevious: false, canNext: false, positionLabel: "Page 2 / 12", onPrevious: () => undefined, onNext: () => undefined }} + > +

Page courante

+
+ ); + + expect(html).toContain("Cette page n’a pas pu être chargée. Votre position est conservée."); + expect(html).toContain("Reessayer"); + }); +}); diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index f00569e..80f1641 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -1,6 +1,5 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { epubFileName } from "./EpubReader"; import { pdfDocumentOptions } from "./pdfDocumentOptions"; import { clampReaderPage, @@ -23,11 +22,6 @@ import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from import { readerErrorMessage } from "./ReaderError"; describe("reader runtime helpers", () => { - it("extracts an EPUB file name from the file URL", () => { - expect(epubFileName("http://readabook.local/books/12/file?token=abc")).toBe("book.epub"); - expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub"); - }); - it("keeps PDF.js worker fallback source on the bundled module worker", () => { expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs"); }); diff --git a/apps/web/src/reader/useAdaptiveReading.test.ts b/apps/web/src/reader/useAdaptiveReading.test.ts new file mode 100644 index 0000000..5f9dd11 --- /dev/null +++ b/apps/web/src/reader/useAdaptiveReading.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { adaptiveStatusLabel } from "./useAdaptiveReading"; + +describe("adaptive reading status", () => { + it("uses the short reader wording without a global percentage", () => { + expect(adaptiveStatusLabel("opening", false)).toBe("Ouverture de la page…"); + expect(adaptiveStatusLabel("next", false)).toBe("Préparation de la page suivante…"); + expect(adaptiveStatusLabel("neighbors", false)).toBe("Chargement des pages voisines…"); + expect(adaptiveStatusLabel("slow", false)).toBe("Connexion lente. On charge d’abord cette page."); + expect(adaptiveStatusLabel("offline", false)).toBe("Hors connexion. La page déjà chargée reste disponible."); + + for (const status of ["opening", "next", "neighbors", "slow", "offline", "idle"] as const) { + const label = adaptiveStatusLabel(status, false) ?? ""; + expect(label).not.toMatch(/%|pourcentage|global|manifest|spine|range|etag|cache|resource|locator/i); + } + }); + + it("announces a lightweight resource when the manifest supports it", () => { + expect(adaptiveStatusLabel("opening", true)).toBe("Version allégée"); + }); +}); diff --git a/apps/web/src/reader/useAdaptiveReading.ts b/apps/web/src/reader/useAdaptiveReading.ts new file mode 100644 index 0000000..72000c5 --- /dev/null +++ b/apps/web/src/reader/useAdaptiveReading.ts @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "../api/client"; +import type { ReadingConnectionMode, ReadingManifestDto, ReadingNetworkHint, ReadingWindowItemDto } from "../api/types"; + +const CONNECTION_MODE_KEY = "readabook:reading-connection-mode"; + +export type AdaptiveReadingStatus = "opening" | "next" | "neighbors" | "slow" | "offline" | "idle"; + +function storedMode(): ReadingConnectionMode { + if (typeof localStorage === "undefined") return "auto"; + const value = localStorage.getItem(CONNECTION_MODE_KEY); + return value === "speed" || value === "quality" ? value : "auto"; +} + +export function readingNetworkHint(): ReadingNetworkHint { + if (typeof navigator === "undefined") return "unknown"; + if (!navigator.onLine) return "offline"; + const connection = (navigator as Navigator & { connection?: { effectiveType?: string; saveData?: boolean } }).connection; + if (connection?.saveData || connection?.effectiveType === "slow-2g" || connection?.effectiveType === "2g") return "slow"; + if (connection?.effectiveType === "3g") return "normal"; + if (connection?.effectiveType === "4g") return "fast"; + return "unknown"; +} + +export function adaptiveStatusLabel(status: AdaptiveReadingStatus, lightweight: boolean): string | undefined { + if (lightweight) return "Version allégée"; + if (status === "opening") return "Ouverture de la page…"; + if (status === "next") return "Préparation de la page suivante…"; + if (status === "neighbors") return "Chargement des pages voisines…"; + if (status === "slow") return "Connexion lente. On charge d’abord cette page."; + if (status === "offline") return "Hors connexion. La page déjà chargée reste disponible."; + return undefined; +} + +export function useAdaptiveReading(bookId: number, enabled: boolean) { + const [manifest, setManifest] = useState(null); + const [mode, setModeState] = useState(storedMode); + const [status, setStatus] = useState("opening"); + const [error, setError] = useState(); + const generationRef = useRef(0); + const lastLocatorRef = useRef(null); + const currentResourceRef = useRef(null); + + const loadManifest = useCallback(async () => { + if (!enabled) return; + setError(undefined); + setStatus("opening"); + try { + setManifest(await api.readingManifest(bookId)); + } catch { + setError("Impossible de préparer la lecture. Réessayez sans quitter cette page."); + setStatus(readingNetworkHint() === "offline" ? "offline" : "idle"); + } + }, [bookId, enabled]); + + useEffect(() => { + setManifest(null); + void loadManifest(); + }, [loadManifest]); + + useEffect(() => { + const update = () => setStatus(navigator.onLine ? "idle" : "offline"); + window.addEventListener("online", update); + window.addEventListener("offline", update); + return () => { + window.removeEventListener("online", update); + window.removeEventListener("offline", update); + }; + }, []); + + const setMode = useCallback((next: ReadingConnectionMode) => { + setModeState(next); + localStorage.setItem(CONNECTION_MODE_KEY, next); + }, []); + + const prioritize = useCallback( + async (locator: string, prefetch = true) => { + if (!manifest) return; + lastLocatorRef.current = locator; + const generation = ++generationRef.current; + const networkHint = readingNetworkHint(); + setError(undefined); + setStatus(networkHint === "offline" ? "offline" : networkHint === "slow" || mode === "speed" ? "slow" : "opening"); + try { + const window = await api.readingWindow(bookId, locator, mode, networkHint); + currentResourceRef.current = window.current?.resourceId ?? null; + if (generation !== generationRef.current || networkHint === "offline") return; + // pdf.js owns its byte-range requests; fetching the resource here would download the whole PDF. + if (manifest.format === "pdf" || !prefetch) { + setStatus("idle"); + return; + } + const fetchItem = async (item: ReadingWindowItemDto | null) => { + if (!item) return; + const response = await fetch(api.readingResourceUrl(bookId, item.resourceId), { credentials: "include", cache: "default" }); + if (!response.ok) throw new Error(String(response.status)); + await response.arrayBuffer(); + }; + await fetchItem(window.current); + if (generation !== generationRef.current) return; + setStatus("next"); + await Promise.allSettled([fetchItem(window.previous), fetchItem(window.next)]); + if (generation !== generationRef.current) return; + if (window.neighbors.length) { + setStatus("neighbors"); + await Promise.allSettled(window.neighbors.map(fetchItem)); + } + if (generation === generationRef.current) setStatus("idle"); + } catch { + if (generation !== generationRef.current) return; + setError("Cette page n’a pas pu être chargée. Votre position est conservée."); + setStatus(readingNetworkHint() === "offline" ? "offline" : "idle"); + } + }, + [bookId, manifest, mode] + ); + + const retry = useCallback(async () => { + const resourceId = currentResourceRef.current; + const locator = lastLocatorRef.current; + if (!resourceId || !locator) { + await loadManifest(); + return; + } + try { + await api.retryReadingResource(bookId, resourceId); + } catch { + // Retrying the GET still gives an already cached response a chance while offline. + } + await prioritize(locator); + }, [bookId, loadManifest, prioritize]); + + return { manifest, mode, setMode, status, statusLabel: adaptiveStatusLabel(status, Boolean(manifest?.capabilities.lightweightPages)), error, retry, prioritize }; +} diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 3df69ad..b9ddc2d 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -1209,6 +1209,17 @@ main.app-main-reader .reader-page { min-width: 0; } +.reader-activity { + justify-content: center; + min-height: 30px; + color: var(--ink-muted); + font-size: 0.86rem; +} + +.reader-connection-control select { + max-width: 220px; +} + .reader-stage { grid-row: 3; position: relative; @@ -1536,6 +1547,11 @@ main.app-main-reader .reader-page { min-width: 0; } + .reader-connection-control { + order: 8; + flex-basis: 100%; + } + .reader-mode-control select { width: 100%; min-width: 0; @@ -1566,7 +1582,7 @@ main.app-main-reader .reader-page { .reader-back-button span, .reader-nav-button span, .reader-fullscreen-toggle span, - .reader-mode-control > span { + .reader-mode-control > span:not(:only-child) { display: none; } diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 6a99ea7..0e663a1 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,9 +1,14 @@ /// declare module "foliate-js/epub.js" { - const module: unknown; - export default module; - export const EPUB: unknown; + export class EPUB { + constructor(loader: { + loadText(path: string): Promise; + loadBlob(path: string): Promise; + getSize(path: string): number; + }); + init(): Promise; + } } declare module "foliate-js/view.js" {