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 { ProgressModule } from "./progress/progress.module.js";
|
||||
import { ReaderModule } from "./reader/reader.module.js";
|
||||
import { ReadingModule } from "./reading/reading.module.js";
|
||||
import { ScannerModule } from "./scanner/scanner.module.js";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule],
|
||||
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ReadingModule, ScannerModule, AutomationModule],
|
||||
controllers: [HealthController]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
it("sends the adaptive reading window with the selected network policy", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ bookId: 12, locator: "chapter-2", current: null, previous: null, next: null, neighbors: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.readingWindow(12, "chapter-2", "speed", "slow");
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/books/12/reading-window");
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ locator: "chapter-2", mode: "speed", networkHint: "slow" });
|
||||
});
|
||||
|
||||
it("builds an encoded reading resource URL", () => {
|
||||
expect(api.readingResourceUrl(12, "chapter/one")).toBe("/books/12/reading-resources/chapter%2Fone");
|
||||
});
|
||||
|
||||
it("surfaces create library API errors without fallback", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ message: "Library path does not exist" }), {
|
||||
|
||||
@ -27,7 +27,16 @@ import {
|
||||
mockProgress,
|
||||
mockUser
|
||||
} from "./mockData";
|
||||
import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types";
|
||||
import type {
|
||||
CbzPagesDto,
|
||||
ContinueItem,
|
||||
ReaderPreferencesDto,
|
||||
ReadingConnectionMode,
|
||||
ReadingManifestDto,
|
||||
ReadingNetworkHint,
|
||||
ReadingWindowDto,
|
||||
Session
|
||||
} from "./types";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
|
||||
@ -179,6 +188,21 @@ export const api = {
|
||||
bookCoverUrl(id: number): string {
|
||||
return `${API_BASE}/books/${id}/cover`;
|
||||
},
|
||||
async readingManifest(id: number): Promise<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> {
|
||||
return request<CbzPagesDto>(`/books/${id}/pages`);
|
||||
},
|
||||
|
||||
@ -32,6 +32,63 @@ export type ReaderMode = "horizontal" | "vertical";
|
||||
|
||||
export type ReaderFit = "page" | "width";
|
||||
|
||||
export type ReadingConnectionMode = "auto" | "speed" | "quality";
|
||||
export type ReadingNetworkHint = "offline" | "slow" | "normal" | "fast" | "unknown";
|
||||
|
||||
export type ReadingResourceDto = {
|
||||
resourceId: string;
|
||||
href: string;
|
||||
url: string;
|
||||
kind: "chapter" | "image" | "stylesheet" | "font" | "document";
|
||||
mediaType: string;
|
||||
size: number;
|
||||
state: "ready" | "pending" | "unsupported" | "failed";
|
||||
references?: string[];
|
||||
};
|
||||
|
||||
export type ReadingSpineItemDto = {
|
||||
index: number;
|
||||
href: string;
|
||||
resourceId: string;
|
||||
url: string;
|
||||
mediaType: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type ReadingManifestDto = {
|
||||
bookId: number;
|
||||
format: "epub" | "pdf";
|
||||
revision: string;
|
||||
version?: string;
|
||||
containerPath: string | null;
|
||||
opfPath: string | null;
|
||||
basePath: string | null;
|
||||
resources: ReadingResourceDto[];
|
||||
spine: ReadingSpineItemDto[];
|
||||
capabilities: {
|
||||
byteRanges: boolean;
|
||||
derivedImages: boolean;
|
||||
lightweightPages: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ReadingWindowItemDto = {
|
||||
resourceId: string;
|
||||
relation: "current" | "previous" | "next" | "neighbor";
|
||||
state: ReadingResourceDto["state"];
|
||||
};
|
||||
|
||||
export type ReadingWindowDto = {
|
||||
bookId: number;
|
||||
locator: string;
|
||||
mode: ReadingConnectionMode;
|
||||
networkHint: ReadingNetworkHint;
|
||||
current: ReadingWindowItemDto | null;
|
||||
previous: ReadingWindowItemDto | null;
|
||||
next: ReadingWindowItemDto | null;
|
||||
neighbors: ReadingWindowItemDto[];
|
||||
};
|
||||
|
||||
export type ReaderPreferencesDto = {
|
||||
mode: ReaderMode;
|
||||
fit?: ReaderFit;
|
||||
|
||||
@ -10,6 +10,7 @@ import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reade
|
||||
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
|
||||
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||
import { useAdaptiveReading } from "../reader/useAdaptiveReading";
|
||||
import { navigate } from "../router";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
@ -69,6 +70,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
||||
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
||||
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
|
||||
const adaptive = useAdaptiveReading(bookId, book?.format === "epub" || book?.format === "pdf");
|
||||
|
||||
async function loadBook() {
|
||||
setLoading(true);
|
||||
@ -104,7 +106,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
||||
}, [progress]);
|
||||
|
||||
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
|
||||
const legacyFileUrl = useMemo(() => (book?.format === "epub" ? "" : api.bookFileUrl(bookId)), [book?.format, bookId]);
|
||||
const adaptiveFileUrl = useMemo(() => {
|
||||
if (book?.format !== "pdf" || !adaptive.manifest) return legacyFileUrl;
|
||||
return adaptive.manifest.spine[0]?.url ?? api.readingResourceUrl(bookId, "original");
|
||||
}, [adaptive.manifest, book?.format, bookId, legacyFileUrl]);
|
||||
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
|
||||
const savePdfPage = useCallback(
|
||||
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
||||
@ -113,10 +119,19 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
const percent = pdfPagePercent(nextPage, pages, anchor);
|
||||
if (strategy === "queued") queueSave(locator, percent);
|
||||
else void save(locator, percent);
|
||||
void adaptive.prioritize(adaptive.manifest?.spine[0]?.resourceId ?? "original");
|
||||
},
|
||||
[queueSave, save]
|
||||
[adaptive.manifest?.spine, adaptive.prioritize, queueSave, save]
|
||||
);
|
||||
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
||||
const saveEpubLocator = useCallback((locator: string, percent: number, readingLocator: string) => {
|
||||
void save(locator, percent);
|
||||
void adaptive.prioritize(readingLocator);
|
||||
}, [adaptive.prioritize, save]);
|
||||
|
||||
useEffect(() => {
|
||||
const first = adaptive.manifest?.spine[0]?.resourceId;
|
||||
if (first) void adaptive.prioritize(first);
|
||||
}, [adaptive.manifest?.revision, adaptive.mode]);
|
||||
const saveComicPage = useCallback(
|
||||
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
||||
setPage(nextPage);
|
||||
@ -129,7 +144,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
[book?.format, queueSave, save]
|
||||
);
|
||||
|
||||
const readerError = error ?? progressError ?? preferencesError;
|
||||
const readerError = error ?? progressError ?? preferencesError ?? adaptive.error;
|
||||
const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr";
|
||||
const supportsMode = supportsZoom;
|
||||
const currentVisiblePage = useCallback(() => {
|
||||
@ -231,10 +246,12 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
title={book?.title ?? "Ouverture du lecteur"}
|
||||
backHref={backHref}
|
||||
error={readerError}
|
||||
onRetry={error ? () => void loadBook() : undefined}
|
||||
controls={readerControls}
|
||||
zoomControls={zoomControls}
|
||||
modeControls={modeControls}
|
||||
connectionControls={book.format === "epub" || book.format === "pdf" ? { mode: adaptive.mode, onModeChange: adaptive.setMode } : undefined}
|
||||
activity={book.format === "epub" || book.format === "pdf" ? adaptive.statusLabel : undefined}
|
||||
onRetry={error ? () => void loadBook() : adaptive.error ? adaptive.retry : undefined}
|
||||
>
|
||||
<ReaderCrashBoundary
|
||||
resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
|
||||
@ -273,7 +290,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
>
|
||||
{book.format === "pdf" ? (
|
||||
<PdfReader
|
||||
url={fileUrl}
|
||||
url={adaptiveFileUrl}
|
||||
page={page}
|
||||
backHref={backHref}
|
||||
zoom={zoom}
|
||||
@ -290,15 +307,17 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
||||
onPageCommit={saveComicPage}
|
||||
onControlsChange={setReaderControls}
|
||||
/>
|
||||
) : (
|
||||
) : adaptive.manifest?.format === "epub" ? (
|
||||
<EpubReader
|
||||
url={fileUrl}
|
||||
manifest={adaptive.manifest}
|
||||
locator={progress?.locator}
|
||||
backHref={backHref}
|
||||
mode="horizontal"
|
||||
onLocatorChange={saveEpubLocator}
|
||||
onControlsChange={setReaderControls}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-fallback"><span>Ouverture de la page…</span></div>
|
||||
)}
|
||||
</ReaderCrashBoundary>
|
||||
</ReaderShell>
|
||||
|
||||
@ -1,36 +1,60 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReadingManifestDto, ReaderMode } from "../api/types";
|
||||
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
||||
import type { ReaderControls } from "./ReaderShell";
|
||||
import type { ReaderMode } from "../api/types";
|
||||
|
||||
type FoliateLocation = {
|
||||
cfi?: string;
|
||||
fraction?: number;
|
||||
current?: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
type FoliateLocation = { cfi?: string; fraction?: number; current?: number; total?: number };
|
||||
type FoliateView = HTMLElement & {
|
||||
open(input: File | Blob | string): Promise<void>;
|
||||
open(input: unknown): Promise<void>;
|
||||
close(): void;
|
||||
goLeft(): Promise<void>;
|
||||
goRight(): Promise<void>;
|
||||
goTo(target: string): Promise<unknown>;
|
||||
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 {
|
||||
const base = globalThis.location?.href ?? "http://readabook.local/";
|
||||
const pathname = new URL(url, base).pathname;
|
||||
const name = pathname.split("/").filter(Boolean).at(-1);
|
||||
return name && name.includes(".") ? name : "book.epub";
|
||||
return decodeURI(withoutFragment).replace(/^\.\//, "").replace(/^\/+/, "");
|
||||
} catch {
|
||||
return "book.epub";
|
||||
return withoutFragment.replace(/^\.\//, "").replace(/^\/+/, "");
|
||||
}
|
||||
}
|
||||
|
||||
export function createEpubApiLoader(manifest: ReadingManifestDto, fetcher: typeof fetch = fetch): EpubApiLoader {
|
||||
const resources = new Map(manifest.resources.map((resource) => [normalizedResourcePath(resource.href), resource]));
|
||||
const responses = new Map<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 {
|
||||
if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
|
||||
if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
|
||||
@ -39,24 +63,18 @@ function locationPercent(location: FoliateLocation): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function EpubReader({
|
||||
url,
|
||||
locator,
|
||||
backHref,
|
||||
mode,
|
||||
onLocatorChange,
|
||||
onControlsChange
|
||||
}: {
|
||||
url: string;
|
||||
export function EpubReader({ manifest, locator, backHref, mode, onLocatorChange, onControlsChange }: {
|
||||
manifest: ReadingManifestDto;
|
||||
locator?: string;
|
||||
backHref: string;
|
||||
mode: ReaderMode;
|
||||
onLocatorChange: (locator: string, percent: number) => void;
|
||||
onLocatorChange: (locator: string, percent: number, readingLocator: string) => void;
|
||||
onControlsChange: (controls: ReaderControls) => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<FoliateView | null>(null);
|
||||
const locatorRef = useRef(locator);
|
||||
const sectionIndexRef = useRef(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
@ -65,43 +83,39 @@ export function EpubReader({
|
||||
onControlsChange({
|
||||
canPrevious: !loading && !error,
|
||||
canNext: !loading && !error,
|
||||
positionLabel: loading ? "Ouverture EPUB" : "Lecture integree",
|
||||
positionLabel: loading ? "Ouverture de la page…" : "Lecture intégrée",
|
||||
onPrevious: () => void viewRef.current?.goLeft(),
|
||||
onNext: () => void viewRef.current?.goRight()
|
||||
});
|
||||
}, [error, loading, onControlsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
locatorRef.current = locator;
|
||||
}, [locator]);
|
||||
useEffect(() => { locatorRef.current = locator; }, [locator]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let view: FoliateView | null = null;
|
||||
|
||||
async function mount() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
await import("foliate-js/view.js");
|
||||
const [{ EPUB }] = await Promise.all([import("foliate-js/epub.js"), import("foliate-js/view.js")]);
|
||||
if (cancelled || !hostRef.current) return;
|
||||
|
||||
const response = await fetch(url, { credentials: "include" });
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||||
const blob = await response.blob();
|
||||
const book = await new EPUB(createEpubApiLoader(manifest)).init();
|
||||
if (cancelled || !hostRef.current) return;
|
||||
|
||||
view = document.createElement("foliate-view") as FoliateView;
|
||||
view.classList.add("epub-view");
|
||||
view.addEventListener("load", (event) => {
|
||||
const index = (event as CustomEvent<{ index?: number }>).detail?.index;
|
||||
if (typeof index === "number") sectionIndexRef.current = index;
|
||||
});
|
||||
view.addEventListener("relocate", (event) => {
|
||||
const location = (event as CustomEvent<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);
|
||||
viewRef.current = view;
|
||||
|
||||
const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
|
||||
await view.open(file);
|
||||
await view.open(book);
|
||||
if (cancelled) return;
|
||||
if (locatorRef.current) await view.goTo(locatorRef.current);
|
||||
else await view.next();
|
||||
@ -113,7 +127,6 @@ export function EpubReader({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void mount();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@ -121,7 +134,7 @@ export function EpubReader({
|
||||
view?.remove();
|
||||
if (viewRef.current === view) viewRef.current = null;
|
||||
};
|
||||
}, [attempt, onLocatorChange, url]);
|
||||
}, [attempt, manifest, onLocatorChange]);
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
|
||||
@ -129,28 +142,10 @@ export function EpubReader({
|
||||
}, [mode]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<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"><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>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`epub-reader epub-reader-${mode}`}>
|
||||
{loading && (
|
||||
<div className="reader-fallback">
|
||||
<span>Ouverture EPUB</span>
|
||||
</div>
|
||||
)}
|
||||
return <div className={`epub-reader epub-reader-${mode}`}>
|
||||
{loading && <div className="reader-fallback"><span>Ouverture de la page…</span></div>}
|
||||
<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({
|
||||
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
|
||||
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
|
||||
positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages),
|
||||
positionLabel: loadingDocument ? "Ouverture de la page…" : readerPositionLabel(currentPage, pages),
|
||||
onPrevious: () => go(currentPage - 1),
|
||||
onNext: () => go(currentPage + 1)
|
||||
});
|
||||
@ -584,7 +584,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
||||
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
|
||||
{loadingDocument || !documentProxy ? (
|
||||
<div className="reader-fallback">
|
||||
<span>Ouverture PDF</span>
|
||||
<span>Ouverture de la page…</span>
|
||||
</div>
|
||||
) : mode === "vertical" ? (
|
||||
<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}>
|
||||
{(!viewportSize || loadingPage || !pageRendered) && (
|
||||
<div className="reader-fallback">
|
||||
<span>Page {currentPage}</span>
|
||||
<span>Ouverture de la page…</span>
|
||||
</div>
|
||||
)}
|
||||
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
|
||||
|
||||
@ -18,7 +18,7 @@ export function ReaderError({
|
||||
title: string;
|
||||
detail: string;
|
||||
technicalDetail?: string;
|
||||
downloadUrl: string;
|
||||
downloadUrl?: string;
|
||||
backHref: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
@ -37,10 +37,12 @@ export function ReaderError({
|
||||
<ArrowLeft size={16} />
|
||||
Retour à la fiche
|
||||
</button>
|
||||
{downloadUrl && (
|
||||
<a className="ghost-button" href={downloadUrl} download>
|
||||
<Download size={16} />
|
||||
Télécharger
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{technicalDetail && (
|
||||
<details>
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures";
|
||||
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
|
||||
import type { ReaderMode } from "./readerScroll";
|
||||
import type { ReadingConnectionMode } from "../api/types";
|
||||
|
||||
export type ReaderControls = {
|
||||
canPrevious: boolean;
|
||||
@ -36,6 +37,11 @@ export type ReaderModeControls = {
|
||||
onModeChange: (mode: ReaderMode) => void;
|
||||
};
|
||||
|
||||
export type ReaderConnectionControls = {
|
||||
mode: ReadingConnectionMode;
|
||||
onModeChange: (mode: ReadingConnectionMode) => void;
|
||||
};
|
||||
|
||||
type ReaderShellProps = {
|
||||
title: string;
|
||||
backHref: string;
|
||||
@ -44,6 +50,8 @@ type ReaderShellProps = {
|
||||
controls: ReaderControls;
|
||||
zoomControls?: ReaderZoomControls;
|
||||
modeControls?: ReaderModeControls;
|
||||
connectionControls?: ReaderConnectionControls;
|
||||
activity?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@ -105,7 +113,7 @@ function scheduleReaderZoomAnchorRestore(stage: HTMLElement, anchor: ReaderZoomA
|
||||
});
|
||||
}
|
||||
|
||||
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) {
|
||||
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, connectionControls, activity, children }: ReaderShellProps) {
|
||||
const readerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const stageRef = useRef<HTMLElement>(null);
|
||||
@ -368,6 +376,16 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
||||
</select>
|
||||
</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">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Precedent</span>
|
||||
@ -405,6 +423,11 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!error && activity && (
|
||||
<div className="reader-status reader-activity" role="status" aria-live="polite">
|
||||
<span>{activity}</span>
|
||||
</div>
|
||||
)}
|
||||
<main className="reader-stage" ref={stageRef}>
|
||||
<div className="reader-content">{children}</div>
|
||||
</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 { describe, expect, it } from "vitest";
|
||||
import { epubFileName } from "./EpubReader";
|
||||
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
||||
import {
|
||||
clampReaderPage,
|
||||
@ -23,11 +22,6 @@ import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from
|
||||
import { readerErrorMessage } from "./ReaderError";
|
||||
|
||||
describe("reader runtime helpers", () => {
|
||||
it("extracts an EPUB file name from the file URL", () => {
|
||||
expect(epubFileName("http://readabook.local/books/12/file?token=abc")).toBe("book.epub");
|
||||
expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
|
||||
});
|
||||
|
||||
it("keeps PDF.js worker fallback source on the bundled module worker", () => {
|
||||
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.reader-activity {
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.reader-connection-control select {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.reader-stage {
|
||||
grid-row: 3;
|
||||
position: relative;
|
||||
@ -1536,6 +1547,11 @@ main.app-main-reader .reader-page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reader-connection-control {
|
||||
order: 8;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.reader-mode-control select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@ -1566,7 +1582,7 @@ main.app-main-reader .reader-page {
|
||||
.reader-back-button span,
|
||||
.reader-nav-button span,
|
||||
.reader-fullscreen-toggle span,
|
||||
.reader-mode-control > span {
|
||||
.reader-mode-control > span:not(:only-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
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" />
|
||||
|
||||
declare module "foliate-js/epub.js" {
|
||||
const module: unknown;
|
||||
export default module;
|
||||
export const EPUB: unknown;
|
||||
export class EPUB {
|
||||
constructor(loader: {
|
||||
loadText(path: string): Promise<string | null>;
|
||||
loadBlob(path: string): Promise<Blob | null>;
|
||||
getSize(path: string): number;
|
||||
});
|
||||
init(): Promise<unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "foliate-js/view.js" {
|
||||
|
||||
Reference in New Issue
Block a user