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(); 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 { 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 }; }