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:
Git Agent
2026-09-29 23:56:42 +02:00
parent bf5441d5c4
commit bdc6d58d45
27 changed files with 1384 additions and 95 deletions

View File

@ -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 });
});
});

View 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";
}

View File

@ -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);
}
}