feat(reading): lecture adaptative pour mauvaises connexions — manifest de lecture avec fenêtre priorisée autour de la position du lecteur et préchargement progressif des pages voisines, ressources authentifiées servies avec Range/ETag (module reading API: controller/service/dto/ports, source archive et cache reconstructible filesystem), client web adaptatif useAdaptiveReading avec enrichissement du cache au fil de la lecture, EPUB chargé par ressources manifestées via loader Foliate avec credentials (fini le téléchargement complet du fichier), optimisation des images EPUB et fallback page PDF allégée, avec tests api/web — évolution #53
Ouvrir rapidement un très grand livre et continuer la lecture sans attendre le chargement complet sur très mauvaise connexion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user