feat(reading): lecture adaptative pour mauvaises connexions — manifest de lecture avec fenêtre priorisée autour de la position du lecteur et préchargement progressif des pages voisines, ressources authentifiées servies avec Range/ETag (module reading API: controller/service/dto/ports, source archive et cache reconstructible filesystem), client web adaptatif useAdaptiveReading avec enrichissement du cache au fil de la lecture, EPUB chargé par ressources manifestées via loader Foliate avec credentials (fini le téléchargement complet du fichier), optimisation des images EPUB et fallback page PDF allégée, avec tests api/web — évolution #53
Ouvrir rapidement un très grand livre et continuer la lecture sans attendre le chargement complet sur très mauvaise connexion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -6,11 +6,12 @@ import { BooksModule } from "./books/books.module.js";
|
|||||||
import { DatabaseModule } from "./database/database.module.js";
|
import { DatabaseModule } from "./database/database.module.js";
|
||||||
import { ProgressModule } from "./progress/progress.module.js";
|
import { ProgressModule } from "./progress/progress.module.js";
|
||||||
import { ReaderModule } from "./reader/reader.module.js";
|
import { ReaderModule } from "./reader/reader.module.js";
|
||||||
|
import { ReadingModule } from "./reading/reading.module.js";
|
||||||
import { ScannerModule } from "./scanner/scanner.module.js";
|
import { ScannerModule } from "./scanner/scanner.module.js";
|
||||||
import { HealthController } from "./health.controller.js";
|
import { HealthController } from "./health.controller.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule],
|
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ReadingModule, ScannerModule, AutomationModule],
|
||||||
controllers: [HealthController]
|
controllers: [HealthController]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -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(`<?xml version="1.0"?><container><rootfiles><rootfile full-path="OPS/package.opf"/></rootfiles></container>`));
|
||||||
|
zip.addFile("OPS/package.opf", Buffer.from(`<package><manifest><item id="one" href="chapter-1.xhtml" media-type="application/xhtml+xml"/><item id="pic" href="images/cover.jpg" media-type="image/jpeg"/><item id="two" href="chapter-2.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="one"/><itemref idref="two"/></spine></package>`));
|
||||||
|
zip.addFile("OPS/chapter-1.xhtml", Buffer.from('<html><head><link href="styles/book.css" rel="stylesheet"/></head><body><img src="images/cover.jpg"/><p>First</p></body></html>'));
|
||||||
|
zip.addFile("OPS/chapter-2.xhtml", Buffer.from("<p>Second</p>"));
|
||||||
|
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("<p>First</p>");
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
220
apps/api/src/reading/adapters/archive-reading-source.adapter.ts
Normal file
220
apps/api/src/reading/adapters/archive-reading-source.adapter.ts
Normal file
@ -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<number, { revision: string; manifest: ReadingManifest }>();
|
||||||
|
|
||||||
|
async manifest(book: ReadingBook): Promise<ReadingManifest> {
|
||||||
|
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<ReadingResourcePayload> {
|
||||||
|
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<string, string>();
|
||||||
|
const packageIds = new Map<string, string>();
|
||||||
|
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<string, ReadingResource>();
|
||||||
|
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<string, ReadingResource>): 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";
|
||||||
|
}
|
||||||
@ -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<Buffer | null> {
|
||||||
|
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<void> {
|
||||||
|
const path = this.path(book, resourceId);
|
||||||
|
await mkdir(join(path, ".."), { recursive: true });
|
||||||
|
await writeFile(path, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(book: ReadingBook, resourceId: string): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
apps/api/src/reading/reading.controller.test.ts
Normal file
72
apps/api/src/reading/reading.controller.test.ts
Normal file
@ -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<string, unknown>) {
|
||||||
|
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<string, string>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
50
apps/api/src/reading/reading.controller.ts
Normal file
50
apps/api/src/reading/reading.controller.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/reading/reading.dto.ts
Normal file
9
apps/api/src/reading/reading.dto.ts
Normal file
@ -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<typeof ReadingWindowSchema>;
|
||||||
20
apps/api/src/reading/reading.module.ts
Normal file
20
apps/api/src/reading/reading.module.ts
Normal file
@ -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 {}
|
||||||
12
apps/api/src/reading/reading.ports.ts
Normal file
12
apps/api/src/reading/reading.ports.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { ReadingBook, ReadingManifest, ReadingResourcePayload } from "./reading.types.js";
|
||||||
|
|
||||||
|
export abstract class ReadingSourcePort {
|
||||||
|
abstract manifest(book: ReadingBook): Promise<ReadingManifest>;
|
||||||
|
abstract resource(book: ReadingBook, resourceId: string): Promise<ReadingResourcePayload>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class ReadingCachePort {
|
||||||
|
abstract get(book: ReadingBook, resourceId: string): Promise<Buffer | null>;
|
||||||
|
abstract put(book: ReadingBook, resourceId: string, data: Buffer): Promise<void>;
|
||||||
|
abstract remove(book: ReadingBook, resourceId: string): Promise<void>;
|
||||||
|
}
|
||||||
169
apps/api/src/reading/reading.service.test.ts
Normal file
169
apps/api/src/reading/reading.service.test.ts
Normal file
@ -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<ReadingManifest> {
|
||||||
|
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<ReadingResourcePayload> {
|
||||||
|
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<string, Buffer>();
|
||||||
|
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<void>((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStream(stream: NodeJS.ReadableStream | undefined) {
|
||||||
|
return new Promise<Buffer>((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)));
|
||||||
|
});
|
||||||
|
}
|
||||||
168
apps/api/src/reading/reading.service.ts
Normal file
168
apps/api/src/reading/reading.service.ts
Normal file
@ -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<string>();
|
||||||
|
|
||||||
|
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<ReadingResourcePayload & { stream?: NodeJS.ReadableStream; start: number; end: number; partial: boolean }> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
63
apps/api/src/reading/reading.types.ts
Normal file
63
apps/api/src/reading/reading.types.ts
Normal file
@ -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";
|
||||||
|
};
|
||||||
@ -59,6 +59,27 @@ describe("api fallback helpers", () => {
|
|||||||
expect(headers.has("Content-Type")).toBe(false);
|
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 () => {
|
it("surfaces create library API errors without fallback", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
new Response(JSON.stringify({ message: "Library path does not exist" }), {
|
new Response(JSON.stringify({ message: "Library path does not exist" }), {
|
||||||
|
|||||||
@ -27,7 +27,16 @@ import {
|
|||||||
mockProgress,
|
mockProgress,
|
||||||
mockUser
|
mockUser
|
||||||
} from "./mockData";
|
} 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 API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
|
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
|
||||||
@ -179,6 +188,21 @@ export const api = {
|
|||||||
bookCoverUrl(id: number): string {
|
bookCoverUrl(id: number): string {
|
||||||
return `${API_BASE}/books/${id}/cover`;
|
return `${API_BASE}/books/${id}/cover`;
|
||||||
},
|
},
|
||||||
|
async readingManifest(id: number): Promise<ReadingManifestDto> {
|
||||||
|
return request<ReadingManifestDto>(`/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<ReadingWindowDto> {
|
||||||
|
return request<ReadingWindowDto>(`/books/${id}/reading-window`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ locator, mode, networkHint })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async retryReadingResource(id: number, resourceId: string): Promise<void> {
|
||||||
|
await request(`/books/${id}/reading-resources/${encodeURIComponent(resourceId)}/retry`, { method: "POST" });
|
||||||
|
},
|
||||||
async cbzPages(id: number): Promise<CbzPagesDto> {
|
async cbzPages(id: number): Promise<CbzPagesDto> {
|
||||||
return request<CbzPagesDto>(`/books/${id}/pages`);
|
return request<CbzPagesDto>(`/books/${id}/pages`);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -32,6 +32,63 @@ export type ReaderMode = "horizontal" | "vertical";
|
|||||||
|
|
||||||
export type ReaderFit = "page" | "width";
|
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 = {
|
export type ReaderPreferencesDto = {
|
||||||
mode: ReaderMode;
|
mode: ReaderMode;
|
||||||
fit?: ReaderFit;
|
fit?: ReaderFit;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reade
|
|||||||
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
|
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
|
||||||
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
||||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||||
|
import { useAdaptiveReading } from "../reader/useAdaptiveReading";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||||
|
|
||||||
@ -69,6 +70,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
||||||
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
||||||
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
|
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
|
||||||
|
const adaptive = useAdaptiveReading(bookId, book?.format === "epub" || book?.format === "pdf");
|
||||||
|
|
||||||
async function loadBook() {
|
async function loadBook() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -104,7 +106,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
||||||
}, [progress]);
|
}, [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 backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
|
||||||
const savePdfPage = useCallback(
|
const savePdfPage = useCallback(
|
||||||
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
(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);
|
const percent = pdfPagePercent(nextPage, pages, anchor);
|
||||||
if (strategy === "queued") queueSave(locator, percent);
|
if (strategy === "queued") queueSave(locator, percent);
|
||||||
else void save(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(
|
const saveComicPage = useCallback(
|
||||||
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
@ -129,7 +144,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
[book?.format, queueSave, save]
|
[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 supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr";
|
||||||
const supportsMode = supportsZoom;
|
const supportsMode = supportsZoom;
|
||||||
const currentVisiblePage = useCallback(() => {
|
const currentVisiblePage = useCallback(() => {
|
||||||
@ -231,10 +246,12 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
title={book?.title ?? "Ouverture du lecteur"}
|
title={book?.title ?? "Ouverture du lecteur"}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
error={readerError}
|
error={readerError}
|
||||||
onRetry={error ? () => void loadBook() : undefined}
|
|
||||||
controls={readerControls}
|
controls={readerControls}
|
||||||
zoomControls={zoomControls}
|
zoomControls={zoomControls}
|
||||||
modeControls={modeControls}
|
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}
|
||||||
>
|
>
|
||||||
<ReaderCrashBoundary
|
<ReaderCrashBoundary
|
||||||
resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
|
resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
|
||||||
@ -273,7 +290,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
>
|
>
|
||||||
{book.format === "pdf" ? (
|
{book.format === "pdf" ? (
|
||||||
<PdfReader
|
<PdfReader
|
||||||
url={fileUrl}
|
url={adaptiveFileUrl}
|
||||||
page={page}
|
page={page}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
@ -290,15 +307,17 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
onPageCommit={saveComicPage}
|
onPageCommit={saveComicPage}
|
||||||
onControlsChange={setReaderControls}
|
onControlsChange={setReaderControls}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : adaptive.manifest?.format === "epub" ? (
|
||||||
<EpubReader
|
<EpubReader
|
||||||
url={fileUrl}
|
manifest={adaptive.manifest}
|
||||||
locator={progress?.locator}
|
locator={progress?.locator}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
onLocatorChange={saveEpubLocator}
|
onLocatorChange={saveEpubLocator}
|
||||||
onControlsChange={setReaderControls}
|
onControlsChange={setReaderControls}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="reader-fallback"><span>Ouverture de la page…</span></div>
|
||||||
)}
|
)}
|
||||||
</ReaderCrashBoundary>
|
</ReaderCrashBoundary>
|
||||||
</ReaderShell>
|
</ReaderShell>
|
||||||
|
|||||||
@ -1,36 +1,60 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { ReadingManifestDto, ReaderMode } from "../api/types";
|
||||||
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
||||||
import type { ReaderControls } from "./ReaderShell";
|
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 & {
|
type FoliateView = HTMLElement & {
|
||||||
open(input: File | Blob | string): Promise<void>;
|
open(input: unknown): Promise<void>;
|
||||||
close(): void;
|
close(): void;
|
||||||
goLeft(): Promise<void>;
|
goLeft(): Promise<void>;
|
||||||
goRight(): Promise<void>;
|
goRight(): Promise<void>;
|
||||||
goTo(target: string): Promise<unknown>;
|
goTo(target: string): Promise<unknown>;
|
||||||
next(): Promise<void>;
|
next(): Promise<void>;
|
||||||
lastLocation?: FoliateLocation;
|
};
|
||||||
|
type EpubApiLoader = {
|
||||||
|
loadText(path: string): Promise<string | null>;
|
||||||
|
loadBlob(path: string): Promise<Blob | null>;
|
||||||
|
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 {
|
try {
|
||||||
const base = globalThis.location?.href ?? "http://readabook.local/";
|
return decodeURI(withoutFragment).replace(/^\.\//, "").replace(/^\/+/, "");
|
||||||
const pathname = new URL(url, base).pathname;
|
|
||||||
const name = pathname.split("/").filter(Boolean).at(-1);
|
|
||||||
return name && name.includes(".") ? name : "book.epub";
|
|
||||||
} catch {
|
} 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<string, Promise<Blob>>();
|
||||||
|
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 {
|
function locationPercent(location: FoliateLocation): number {
|
||||||
if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
|
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) {
|
if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
|
||||||
@ -39,24 +63,18 @@ function locationPercent(location: FoliateLocation): number {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EpubReader({
|
export function EpubReader({ manifest, locator, backHref, mode, onLocatorChange, onControlsChange }: {
|
||||||
url,
|
manifest: ReadingManifestDto;
|
||||||
locator,
|
|
||||||
backHref,
|
|
||||||
mode,
|
|
||||||
onLocatorChange,
|
|
||||||
onControlsChange
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
locator?: string;
|
locator?: string;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
mode: ReaderMode;
|
mode: ReaderMode;
|
||||||
onLocatorChange: (locator: string, percent: number) => void;
|
onLocatorChange: (locator: string, percent: number, readingLocator: string) => void;
|
||||||
onControlsChange: (controls: ReaderControls) => void;
|
onControlsChange: (controls: ReaderControls) => void;
|
||||||
}) {
|
}) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<FoliateView | null>(null);
|
const viewRef = useRef<FoliateView | null>(null);
|
||||||
const locatorRef = useRef(locator);
|
const locatorRef = useRef(locator);
|
||||||
|
const sectionIndexRef = useRef(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
@ -65,43 +83,39 @@ export function EpubReader({
|
|||||||
onControlsChange({
|
onControlsChange({
|
||||||
canPrevious: !loading && !error,
|
canPrevious: !loading && !error,
|
||||||
canNext: !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(),
|
onPrevious: () => void viewRef.current?.goLeft(),
|
||||||
onNext: () => void viewRef.current?.goRight()
|
onNext: () => void viewRef.current?.goRight()
|
||||||
});
|
});
|
||||||
}, [error, loading, onControlsChange]);
|
}, [error, loading, onControlsChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { locatorRef.current = locator; }, [locator]);
|
||||||
locatorRef.current = locator;
|
|
||||||
}, [locator]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let view: FoliateView | null = null;
|
let view: FoliateView | null = null;
|
||||||
|
|
||||||
async function mount() {
|
async function mount() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
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;
|
if (cancelled || !hostRef.current) return;
|
||||||
|
const book = await new EPUB(createEpubApiLoader(manifest)).init();
|
||||||
const response = await fetch(url, { credentials: "include" });
|
|
||||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
||||||
const blob = await response.blob();
|
|
||||||
if (cancelled || !hostRef.current) return;
|
if (cancelled || !hostRef.current) return;
|
||||||
|
|
||||||
view = document.createElement("foliate-view") as FoliateView;
|
view = document.createElement("foliate-view") as FoliateView;
|
||||||
view.classList.add("epub-view");
|
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) => {
|
view.addEventListener("relocate", (event) => {
|
||||||
const location = (event as CustomEvent<FoliateLocation>).detail;
|
const location = (event as CustomEvent<FoliateLocation>).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);
|
hostRef.current.replaceChildren(view);
|
||||||
viewRef.current = view;
|
viewRef.current = view;
|
||||||
|
await view.open(book);
|
||||||
const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
|
|
||||||
await view.open(file);
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (locatorRef.current) await view.goTo(locatorRef.current);
|
if (locatorRef.current) await view.goTo(locatorRef.current);
|
||||||
else await view.next();
|
else await view.next();
|
||||||
@ -113,7 +127,6 @@ export function EpubReader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void mount();
|
void mount();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@ -121,7 +134,7 @@ export function EpubReader({
|
|||||||
view?.remove();
|
view?.remove();
|
||||||
if (viewRef.current === view) viewRef.current = null;
|
if (viewRef.current === view) viewRef.current = null;
|
||||||
};
|
};
|
||||||
}, [attempt, onLocatorChange, url]);
|
}, [attempt, manifest, onLocatorChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
|
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
|
||||||
@ -129,28 +142,10 @@ export function EpubReader({
|
|||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <div className="epub-reader"><ReaderError title="Lecture EPUB indisponible" detail="ReadaBook n'a pas pu ouvrir cette page dans le lecteur web." technicalDetail={error} backHref={backHref} onRetry={() => setAttempt((value) => value + 1)} /></div>;
|
||||||
<div className="epub-reader">
|
|
||||||
<ReaderError
|
|
||||||
title="Lecture EPUB indisponible"
|
|
||||||
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
|
|
||||||
technicalDetail={error}
|
|
||||||
downloadUrl={url}
|
|
||||||
backHref={backHref}
|
|
||||||
onRetry={() => setAttempt((value) => value + 1)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return <div className={`epub-reader epub-reader-${mode}`}>
|
||||||
return (
|
{loading && <div className="reader-fallback"><span>Ouverture de la page…</span></div>}
|
||||||
<div className={`epub-reader epub-reader-${mode}`}>
|
<div className="epub-host" ref={hostRef} />
|
||||||
{loading && (
|
</div>;
|
||||||
<div className="reader-fallback">
|
|
||||||
<span>Ouverture EPUB</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="epub-host" ref={hostRef} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
41
apps/web/src/reader/EpubReaderAdaptive.test.ts
Normal file
41
apps/web/src/reader/EpubReaderAdaptive.test.ts
Normal file
@ -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("<html>chapter</html>", { status: 200 }));
|
||||||
|
const loader = createEpubApiLoader(manifest, fetcher);
|
||||||
|
|
||||||
|
await expect(loader.loadText("OPS/text/chapter.xhtml#part")).resolves.toBe("<html>chapter</html>");
|
||||||
|
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()");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -285,7 +285,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
onControlsChange({
|
onControlsChange({
|
||||||
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
|
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
|
||||||
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
|
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),
|
onPrevious: () => go(currentPage - 1),
|
||||||
onNext: () => go(currentPage + 1)
|
onNext: () => go(currentPage + 1)
|
||||||
});
|
});
|
||||||
@ -584,7 +584,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
|
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
|
||||||
{loadingDocument || !documentProxy ? (
|
{loadingDocument || !documentProxy ? (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>Ouverture PDF</span>
|
<span>Ouverture de la page…</span>
|
||||||
</div>
|
</div>
|
||||||
) : mode === "vertical" ? (
|
) : mode === "vertical" ? (
|
||||||
<div className="reader-vertical-stack">
|
<div className="reader-vertical-stack">
|
||||||
@ -619,7 +619,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
||||||
{(!viewportSize || loadingPage || !pageRendered) && (
|
{(!viewportSize || loadingPage || !pageRendered) && (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>Page {currentPage}</span>
|
<span>Ouverture de la page…</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
|
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export function ReaderError({
|
|||||||
title: string;
|
title: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
technicalDetail?: string;
|
technicalDetail?: string;
|
||||||
downloadUrl: string;
|
downloadUrl?: string;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
}) {
|
}) {
|
||||||
@ -37,10 +37,12 @@ export function ReaderError({
|
|||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Retour à la fiche
|
Retour à la fiche
|
||||||
</button>
|
</button>
|
||||||
<a className="ghost-button" href={downloadUrl} download>
|
{downloadUrl && (
|
||||||
<Download size={16} />
|
<a className="ghost-button" href={downloadUrl} download>
|
||||||
Télécharger
|
<Download size={16} />
|
||||||
</a>
|
Télécharger
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{technicalDetail && (
|
{technicalDetail && (
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures";
|
import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures";
|
||||||
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
|
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
|
||||||
import type { ReaderMode } from "./readerScroll";
|
import type { ReaderMode } from "./readerScroll";
|
||||||
|
import type { ReadingConnectionMode } from "../api/types";
|
||||||
|
|
||||||
export type ReaderControls = {
|
export type ReaderControls = {
|
||||||
canPrevious: boolean;
|
canPrevious: boolean;
|
||||||
@ -36,6 +37,11 @@ export type ReaderModeControls = {
|
|||||||
onModeChange: (mode: ReaderMode) => void;
|
onModeChange: (mode: ReaderMode) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReaderConnectionControls = {
|
||||||
|
mode: ReadingConnectionMode;
|
||||||
|
onModeChange: (mode: ReadingConnectionMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type ReaderShellProps = {
|
type ReaderShellProps = {
|
||||||
title: string;
|
title: string;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
@ -44,6 +50,8 @@ type ReaderShellProps = {
|
|||||||
controls: ReaderControls;
|
controls: ReaderControls;
|
||||||
zoomControls?: ReaderZoomControls;
|
zoomControls?: ReaderZoomControls;
|
||||||
modeControls?: ReaderModeControls;
|
modeControls?: ReaderModeControls;
|
||||||
|
connectionControls?: ReaderConnectionControls;
|
||||||
|
activity?: string;
|
||||||
children: ReactNode;
|
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<HTMLDivElement>(null);
|
const readerRef = useRef<HTMLDivElement>(null);
|
||||||
const headerRef = useRef<HTMLElement>(null);
|
const headerRef = useRef<HTMLElement>(null);
|
||||||
const stageRef = useRef<HTMLElement>(null);
|
const stageRef = useRef<HTMLElement>(null);
|
||||||
@ -368,6 +376,16 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{connectionControls && (
|
||||||
|
<label className="reader-mode-control reader-connection-control">
|
||||||
|
<span>Mode connexion lente</span>
|
||||||
|
<select value={connectionControls.mode} onChange={(event) => connectionControls.onModeChange(event.currentTarget.value as ReadingConnectionMode)}>
|
||||||
|
<option value="auto">Automatique</option>
|
||||||
|
<option value="speed">Toujours privilégier la vitesse</option>
|
||||||
|
<option value="quality">Toujours privilégier la qualité</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<button className="ghost-button reader-nav-button reader-previous-button" onClick={controls.onPrevious} disabled={!controls.canPrevious} aria-label="Page precedente">
|
<button className="ghost-button reader-nav-button reader-previous-button" onClick={controls.onPrevious} disabled={!controls.canPrevious} aria-label="Page precedente">
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
<span>Precedent</span>
|
<span>Precedent</span>
|
||||||
@ -405,6 +423,11 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!error && activity && (
|
||||||
|
<div className="reader-status reader-activity" role="status" aria-live="polite">
|
||||||
|
<span>{activity}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<main className="reader-stage" ref={stageRef}>
|
<main className="reader-stage" ref={stageRef}>
|
||||||
<div className="reader-content">{children}</div>
|
<div className="reader-content">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
41
apps/web/src/reader/ReaderShellAdaptive.test.tsx
Normal file
41
apps/web/src/reader/ReaderShellAdaptive.test.tsx
Normal file
@ -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(
|
||||||
|
<ReaderShell
|
||||||
|
title="Livre"
|
||||||
|
backHref="/books/1"
|
||||||
|
controls={{ canPrevious: false, canNext: true, positionLabel: "Page 2 / 12", onPrevious: () => undefined, onNext: () => undefined }}
|
||||||
|
connectionControls={{ mode: "auto", onModeChange: () => undefined }}
|
||||||
|
>
|
||||||
|
<p>Page courante</p>
|
||||||
|
</ReaderShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ReaderShell
|
||||||
|
title="Livre"
|
||||||
|
backHref="/books/1"
|
||||||
|
error="Cette page n’a pas pu être chargée. Votre position est conservée."
|
||||||
|
onRetry={() => undefined}
|
||||||
|
controls={{ canPrevious: false, canNext: false, positionLabel: "Page 2 / 12", onPrevious: () => undefined, onNext: () => undefined }}
|
||||||
|
>
|
||||||
|
<p>Page courante</p>
|
||||||
|
</ReaderShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Cette page n’a pas pu être chargée. Votre position est conservée.");
|
||||||
|
expect(html).toContain("Reessayer");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { epubFileName } from "./EpubReader";
|
|
||||||
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
||||||
import {
|
import {
|
||||||
clampReaderPage,
|
clampReaderPage,
|
||||||
@ -23,11 +22,6 @@ import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from
|
|||||||
import { readerErrorMessage } from "./ReaderError";
|
import { readerErrorMessage } from "./ReaderError";
|
||||||
|
|
||||||
describe("reader runtime helpers", () => {
|
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", () => {
|
it("keeps PDF.js worker fallback source on the bundled module worker", () => {
|
||||||
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
|
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
|
||||||
});
|
});
|
||||||
|
|||||||
21
apps/web/src/reader/useAdaptiveReading.test.ts
Normal file
21
apps/web/src/reader/useAdaptiveReading.test.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
134
apps/web/src/reader/useAdaptiveReading.ts
Normal file
134
apps/web/src/reader/useAdaptiveReading.ts
Normal file
@ -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<ReadingManifestDto | null>(null);
|
||||||
|
const [mode, setModeState] = useState<ReadingConnectionMode>(storedMode);
|
||||||
|
const [status, setStatus] = useState<AdaptiveReadingStatus>("opening");
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const generationRef = useRef(0);
|
||||||
|
const lastLocatorRef = useRef<string | null>(null);
|
||||||
|
const currentResourceRef = useRef<string | null>(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 };
|
||||||
|
}
|
||||||
@ -1209,6 +1209,17 @@ main.app-main-reader .reader-page {
|
|||||||
min-width: 0;
|
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 {
|
.reader-stage {
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -1536,6 +1547,11 @@ main.app-main-reader .reader-page {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-connection-control {
|
||||||
|
order: 8;
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.reader-mode-control select {
|
.reader-mode-control select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -1566,7 +1582,7 @@ main.app-main-reader .reader-page {
|
|||||||
.reader-back-button span,
|
.reader-back-button span,
|
||||||
.reader-nav-button span,
|
.reader-nav-button span,
|
||||||
.reader-fullscreen-toggle span,
|
.reader-fullscreen-toggle span,
|
||||||
.reader-mode-control > span {
|
.reader-mode-control > span:not(:only-child) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
apps/web/src/vite-env.d.ts
vendored
11
apps/web/src/vite-env.d.ts
vendored
@ -1,9 +1,14 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
declare module "foliate-js/epub.js" {
|
declare module "foliate-js/epub.js" {
|
||||||
const module: unknown;
|
export class EPUB {
|
||||||
export default module;
|
constructor(loader: {
|
||||||
export const EPUB: unknown;
|
loadText(path: string): Promise<string | null>;
|
||||||
|
loadBlob(path: string): Promise<Blob | null>;
|
||||||
|
getSize(path: string): number;
|
||||||
|
});
|
||||||
|
init(): Promise<unknown>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "foliate-js/view.js" {
|
declare module "foliate-js/view.js" {
|
||||||
|
|||||||
Reference in New Issue
Block a user