merge: feature/53-adaptive-reading dans develop (lecture adaptative pour mauvaises connexions — manifest, ressources Range/ETag, fenêtre de lecture priorisée, cache reconstructible, EPUB par ressources manifestées et fallback PDF allégé — évolution #53)
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}`}>
|
|
||||||
{loading && (
|
|
||||||
<div className="reader-fallback">
|
|
||||||
<span>Ouverture EPUB</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="epub-host" ref={hostRef} />
|
<div className="epub-host" ref={hostRef} />
|
||||||
</div>
|
</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>
|
||||||
|
{downloadUrl && (
|
||||||
<a className="ghost-button" href={downloadUrl} download>
|
<a className="ghost-button" href={downloadUrl} download>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
Télécharger
|
Télécharger
|
||||||
</a>
|
</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