Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc10a39920 | |||
| bdc6d58d45 | |||
| bf5441d5c4 | |||
| 05f5f1cd16 | |||
| 3faa7ca44b | |||
| d2557c14fa | |||
| 7e6c0bdfab | |||
| 2484fd1e09 |
@ -1,5 +1,9 @@
|
|||||||
# Local compose defaults. Copy to .env when you need machine-specific paths.
|
# Local compose defaults. Copy to .env when you need machine-specific paths.
|
||||||
|
|
||||||
|
# Images used by docker-compose.yaml.
|
||||||
|
READABOOK_REGISTRY=gitea.anthonybouteiller.ovh/blomios/readabook
|
||||||
|
READABOOK_TAG=latest
|
||||||
|
|
||||||
# Directory mounted read-only as /library in the API container.
|
# Directory mounted read-only as /library in the API container.
|
||||||
READABOOK_LIBRARY_HOST_PATH=./data/library
|
READABOOK_LIBRARY_HOST_PATH=./data/library
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,15 @@ RUN pnpm --filter @readabook/api build
|
|||||||
RUN pnpm --filter @readabook/api deploy --prod /prod
|
RUN pnpm --filter @readabook/api deploy --prod /prod
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS runtime
|
FROM node:22-bookworm-slim AS runtime
|
||||||
|
ARG OCI_IMAGE_CREATED
|
||||||
|
ARG OCI_IMAGE_REVISION
|
||||||
|
ARG OCI_IMAGE_VERSION=latest
|
||||||
|
LABEL org.opencontainers.image.title="ReadaBook API" \
|
||||||
|
org.opencontainers.image.description="ReadaBook NestJS API service" \
|
||||||
|
org.opencontainers.image.source="https://gitea.anthonybouteiller.ovh/blomios/ReadaBook" \
|
||||||
|
org.opencontainers.image.created="${OCI_IMAGE_CREATED}" \
|
||||||
|
org.opencontainers.image.revision="${OCI_IMAGE_REVISION}" \
|
||||||
|
org.opencontainers.image.version="${OCI_IMAGE_VERSION}"
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
25
README.md
25
README.md
@ -47,7 +47,8 @@ data/
|
|||||||
library/ Dossier local à scanner, monté en lecture seule dans compose
|
library/ Dossier local à scanner, monté en lecture seule dans compose
|
||||||
storage/ Cache backend, notamment couvertures extraites
|
storage/ Cache backend, notamment couvertures extraites
|
||||||
Dockerfile Image API
|
Dockerfile Image API
|
||||||
docker-compose.yaml Services API + web
|
docker-compose.yaml Services API + web depuis images publiees
|
||||||
|
docker-compose.build.yaml Override de build local des images API + web
|
||||||
pnpm-workspace.yaml Workspace monorepo
|
pnpm-workspace.yaml Workspace monorepo
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -72,7 +73,7 @@ Note sandbox constatée : sur Node 24 local, `better-sqlite3` peut nécessiter u
|
|||||||
## Docker Compose
|
## Docker Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JWT_SECRET="replace-me" docker compose up --build
|
JWT_SECRET="replace-me" docker compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
Services :
|
Services :
|
||||||
@ -85,6 +86,24 @@ Ports :
|
|||||||
- Hôte `3000` -> conteneur `web:80`.
|
- Hôte `3000` -> conteneur `web:80`.
|
||||||
- L’API est accessible depuis le navigateur via les routes proxifiées par Nginx : `/auth`, `/admin`, `/books`, `/progress`, `/healthz`.
|
- L’API est accessible depuis le navigateur via les routes proxifiées par Nginx : `/auth`, `/admin`, `/books`, `/progress`, `/healthz`.
|
||||||
|
|
||||||
|
Images par défaut :
|
||||||
|
|
||||||
|
- `${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/api:${READABOOK_TAG:-latest}`
|
||||||
|
- `${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/web:${READABOOK_TAG:-latest}`
|
||||||
|
|
||||||
|
Pour construire localement au lieu de tirer les images publiées :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
READABOOK_TAG=local docker compose -f docker-compose.yaml -f docker-compose.build.yaml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour publier les images vers le registry Gitea :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login gitea.anthonybouteiller.ovh
|
||||||
|
./scripts/publish.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Frontend PWA
|
## Frontend PWA
|
||||||
|
|
||||||
Le frontend est accessible sur `http://localhost:3000` en compose. Il expose les routes MVP :
|
Le frontend est accessible sur `http://localhost:3000` en compose. Il expose les routes MVP :
|
||||||
@ -108,6 +127,8 @@ Volumes :
|
|||||||
|
|
||||||
Variables principales :
|
Variables principales :
|
||||||
|
|
||||||
|
- `READABOOK_REGISTRY` : préfixe registry/repository des images API et web.
|
||||||
|
- `READABOOK_TAG` : tag d’image utilisé par compose et par `scripts/publish.sh`.
|
||||||
- `JWT_SECRET` : secret JWT, à changer hors développement.
|
- `JWT_SECRET` : secret JWT, à changer hors développement.
|
||||||
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive l’enrichissement distant.
|
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive l’enrichissement distant.
|
||||||
- `READABOOK_LIBRARY_HOST_PATH` : dossier hôte monté en lecture seule sur `/library`.
|
- `READABOOK_LIBRARY_HOST_PATH` : dossier hôte monté en lecture seule sur `/library`.
|
||||||
|
|||||||
@ -6,11 +6,12 @@ import { BooksModule } from "./books/books.module.js";
|
|||||||
import { DatabaseModule } from "./database/database.module.js";
|
import { DatabaseModule } from "./database/database.module.js";
|
||||||
import { ProgressModule } from "./progress/progress.module.js";
|
import { ProgressModule } from "./progress/progress.module.js";
|
||||||
import { ReaderModule } from "./reader/reader.module.js";
|
import { ReaderModule } from "./reader/reader.module.js";
|
||||||
|
import { ReadingModule } from "./reading/reading.module.js";
|
||||||
import { ScannerModule } from "./scanner/scanner.module.js";
|
import { ScannerModule } from "./scanner/scanner.module.js";
|
||||||
import { HealthController } from "./health.controller.js";
|
import { HealthController } from "./health.controller.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule],
|
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ReadingModule, ScannerModule, AutomationModule],
|
||||||
controllers: [HealthController]
|
controllers: [HealthController]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { FastifyReply } from "fastify";
|
|||||||
import { lookup } from "mime-types";
|
import { lookup } from "mime-types";
|
||||||
import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
|
import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
|
||||||
import { AuthGuard } from "../auth/auth.guard.js";
|
import { AuthGuard } from "../auth/auth.guard.js";
|
||||||
|
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
|
||||||
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
|
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
|
||||||
import { BooksService } from "./books.service.js";
|
import { BooksService } from "./books.service.js";
|
||||||
|
|
||||||
@ -12,18 +13,18 @@ export class BooksController {
|
|||||||
constructor(private readonly books: BooksService) {}
|
constructor(private readonly books: BooksService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
|
list(@CurrentUserParam() user: CurrentUser, @Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
|
||||||
return this.books.list(query);
|
return this.books.list(query, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("search")
|
@Get("search")
|
||||||
search(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
|
search(@CurrentUserParam() user: CurrentUser, @Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
|
||||||
return query.q ? this.books.search(query.q, query.limit, query.offset) : [];
|
return query.q ? this.books.search(query.q, query.limit, query.offset, user.id) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
get(@Param("id") id: string) {
|
get(@CurrentUserParam() user: CurrentUser, @Param("id") id: string) {
|
||||||
return this.books.get(Number(id));
|
return this.books.get(Number(id), user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/pages")
|
@Get(":id/pages")
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { extname, join } from "node:path";
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { BookQuerySchema } from "@readabook/shared";
|
import { BookQuerySchema } from "@readabook/shared";
|
||||||
import { DatabaseService } from "../database/database.service.js";
|
import { DatabaseService } from "../database/database.service.js";
|
||||||
import { books, libraries, series } from "../database/schema.js";
|
import { books, libraries, progress, series, users } from "../database/schema.js";
|
||||||
import { BooksService } from "./books.service.js";
|
import { BooksService } from "./books.service.js";
|
||||||
|
|
||||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
@ -149,6 +149,89 @@ describe("BooksService", () => {
|
|||||||
database.onModuleDestroy();
|
database.onModuleDestroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.runIf(canLoadBetterSqlite())("exposes user read status on catalogue, search and book details", () => {
|
||||||
|
const database = createDatabase();
|
||||||
|
const service = new BooksService(database);
|
||||||
|
const now = database.now();
|
||||||
|
const user = database.db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
email: "reader@example.com",
|
||||||
|
name: "Reader",
|
||||||
|
passwordHash: "hash",
|
||||||
|
role: "user",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
const library = database.db
|
||||||
|
.insert(libraries)
|
||||||
|
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
const book = database.db
|
||||||
|
.insert(books)
|
||||||
|
.values({
|
||||||
|
libraryId: library.id,
|
||||||
|
seriesId: null,
|
||||||
|
title: "Daredevil Read State",
|
||||||
|
author: null,
|
||||||
|
description: null,
|
||||||
|
isbn: null,
|
||||||
|
isbn13: null,
|
||||||
|
identifiersJson: null,
|
||||||
|
localMetadataJson: null,
|
||||||
|
language: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedDate: null,
|
||||||
|
volumeNumber: null,
|
||||||
|
volumeLabel: null,
|
||||||
|
format: "cbz",
|
||||||
|
filePath: "/library/Daredevil Read State.cbz",
|
||||||
|
coverPath: null,
|
||||||
|
metadataStatus: "none",
|
||||||
|
metadataProvenanceJson: null,
|
||||||
|
scanStatus: "succeeded",
|
||||||
|
enrichmentStatus: "idle",
|
||||||
|
fileSize: 42,
|
||||||
|
fileMtime: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
database.db
|
||||||
|
.insert(progress)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
bookId: book.id,
|
||||||
|
locator: "manual:read",
|
||||||
|
percent: 100,
|
||||||
|
readStatus: "read",
|
||||||
|
completedAt: now,
|
||||||
|
readStatusSource: "manual",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
expect(service.get(book.id)).toMatchObject({ readStatus: "unread", completedAt: null, readStatusSource: null });
|
||||||
|
expect(service.get(book.id, user.id)).toMatchObject({ readStatus: "read", completedAt: now, readStatusSource: "manual" });
|
||||||
|
expect(service.list({ limit: 50, offset: 0 }, user.id)[0]).toMatchObject({
|
||||||
|
readStatus: "read",
|
||||||
|
completedAt: now,
|
||||||
|
readStatusSource: "manual"
|
||||||
|
});
|
||||||
|
expect(service.search("Daredevil", undefined, 0, user.id)[0]).toMatchObject({
|
||||||
|
readStatus: "read",
|
||||||
|
completedAt: now,
|
||||||
|
readStatusSource: "manual"
|
||||||
|
});
|
||||||
|
|
||||||
|
database.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
it.runIf(canLoadBetterSqlite())("lists a series with distinct books sharing the same volume", () => {
|
it.runIf(canLoadBetterSqlite())("lists a series with distinct books sharing the same volume", () => {
|
||||||
const database = createDatabase();
|
const database = createDatabase();
|
||||||
const service = new BooksService(database);
|
const service = new BooksService(database);
|
||||||
|
|||||||
@ -1,24 +1,32 @@
|
|||||||
import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||||
import { extname } from "node:path";
|
import { extname } from "node:path";
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { BookQueryDto } from "@readabook/shared";
|
import { BookQueryDto } from "@readabook/shared";
|
||||||
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
|
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
|
||||||
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
|
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
|
||||||
import { DatabaseService } from "../database/database.service.js";
|
import { DatabaseService } from "../database/database.service.js";
|
||||||
import { books, series } from "../database/schema.js";
|
import { books, progress, series } from "../database/schema.js";
|
||||||
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
|
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
|
||||||
|
|
||||||
|
type ReadState = {
|
||||||
|
readStatus: "unread" | "reading" | "read";
|
||||||
|
completedAt: string | null;
|
||||||
|
readStatusSource: "progress" | "manual" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unreadState: ReadState = { readStatus: "unread", completedAt: null, readStatusSource: null };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BooksService {
|
export class BooksService {
|
||||||
constructor(private readonly database: DatabaseService) {}
|
constructor(private readonly database: DatabaseService) {}
|
||||||
|
|
||||||
list(query: BookQueryDto) {
|
list(query: BookQueryDto, userId?: number) {
|
||||||
const filters = [];
|
const filters = [];
|
||||||
if (query.format) filters.push(eq(books.format, query.format));
|
if (query.format) filters.push(eq(books.format, query.format));
|
||||||
if (query.libraryId) filters.push(eq(books.libraryId, query.libraryId));
|
if (query.libraryId) filters.push(eq(books.libraryId, query.libraryId));
|
||||||
if (query.q) {
|
if (query.q) {
|
||||||
return this.search(query.q, query.limit, query.offset);
|
return this.search(query.q, query.limit, query.offset, userId);
|
||||||
}
|
}
|
||||||
const statement = this.database.db
|
const statement = this.database.db
|
||||||
.select()
|
.select()
|
||||||
@ -27,11 +35,11 @@ export class BooksService {
|
|||||||
.orderBy(books.title);
|
.orderBy(books.title);
|
||||||
const rows =
|
const rows =
|
||||||
query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all();
|
query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all();
|
||||||
return rows
|
const states = this.readStatesFor(userId, rows.map((book) => book.id));
|
||||||
.map((book) => this.mapBookSelect(book));
|
return rows.map((book) => this.mapBookSelect(book, states.get(book.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
search(q: string, limit?: number, offset = 0) {
|
search(q: string, limit?: number, offset = 0, userId?: number) {
|
||||||
if (limit === undefined) {
|
if (limit === undefined) {
|
||||||
const rows = this.database.sqlite
|
const rows = this.database.sqlite
|
||||||
.prepare(
|
.prepare(
|
||||||
@ -44,7 +52,7 @@ export class BooksService {
|
|||||||
`
|
`
|
||||||
)
|
)
|
||||||
.all(`${q.replace(/"/g, '""')}*`);
|
.all(`${q.replace(/"/g, '""')}*`);
|
||||||
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
|
return this.mapBookRows(rows as Array<Record<string, unknown>>, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = this.database.sqlite
|
const rows = this.database.sqlite
|
||||||
@ -59,18 +67,18 @@ export class BooksService {
|
|||||||
`
|
`
|
||||||
)
|
)
|
||||||
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
|
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
|
||||||
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
|
return this.mapBookRows(rows as Array<Record<string, unknown>>, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(id: number) {
|
get(id: number, userId?: number) {
|
||||||
return this.mapBookSelect(this.getRecord(id));
|
return this.mapBookSelect(this.getRecord(id), this.readStatesFor(userId, [id]).get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
listSeries() {
|
listSeries() {
|
||||||
return this.database.db.select().from(series).orderBy(series.title).all();
|
return this.database.db.select().from(series).orderBy(series.title).all();
|
||||||
}
|
}
|
||||||
|
|
||||||
getSeries(id: number) {
|
getSeries(id: number, userId?: number) {
|
||||||
const row = this.database.db.select().from(series).where(eq(series.id, id)).get();
|
const row = this.database.db.select().from(series).where(eq(series.id, id)).get();
|
||||||
if (!row) throw new NotFoundException("Series not found");
|
if (!row) throw new NotFoundException("Series not found");
|
||||||
const seriesBooks = this.database.db
|
const seriesBooks = this.database.db
|
||||||
@ -78,9 +86,10 @@ export class BooksService {
|
|||||||
.from(books)
|
.from(books)
|
||||||
.where(eq(books.seriesId, id))
|
.where(eq(books.seriesId, id))
|
||||||
.orderBy(books.volumeNumber, books.title)
|
.orderBy(books.volumeNumber, books.title)
|
||||||
.all()
|
.all();
|
||||||
.map((book) => this.mapBookSelect(book));
|
const states = this.readStatesFor(userId, seriesBooks.map((book) => book.id));
|
||||||
return { ...row, books: seriesBooks };
|
const mappedBooks = seriesBooks.map((book) => this.mapBookSelect(book, states.get(book.id)));
|
||||||
|
return { ...row, books: mappedBooks };
|
||||||
}
|
}
|
||||||
|
|
||||||
streamFile(id: number, range?: string) {
|
streamFile(id: number, range?: string) {
|
||||||
@ -155,7 +164,15 @@ export class BooksService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapBookRow(row: Record<string, unknown>) {
|
private mapBookRows(rows: Array<Record<string, unknown>>, userId?: number) {
|
||||||
|
const states = this.readStatesFor(
|
||||||
|
userId,
|
||||||
|
rows.map((row) => Number(row.id))
|
||||||
|
);
|
||||||
|
return rows.map((row) => this.mapBookRow(row, states.get(Number(row.id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapBookRow(row: Record<string, unknown>, readState = unreadState) {
|
||||||
const seriesId = nullable(row.series_id);
|
const seriesId = nullable(row.series_id);
|
||||||
return {
|
return {
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
@ -179,6 +196,7 @@ export class BooksService {
|
|||||||
series: seriesId ? this.getSeriesRecord(Number(seriesId)) : null,
|
series: seriesId ? this.getSeriesRecord(Number(seriesId)) : null,
|
||||||
scanStatus: statusValue(row.scan_status),
|
scanStatus: statusValue(row.scan_status),
|
||||||
enrichmentStatus: statusValue(row.enrichment_status),
|
enrichmentStatus: statusValue(row.enrichment_status),
|
||||||
|
...readState,
|
||||||
fileSize: Number(row.file_size),
|
fileSize: Number(row.file_size),
|
||||||
fileMtime: String(row.file_mtime),
|
fileMtime: String(row.file_mtime),
|
||||||
createdAt: String(row.created_at),
|
createdAt: String(row.created_at),
|
||||||
@ -186,19 +204,44 @@ export class BooksService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapBookSelect(row: typeof books.$inferSelect) {
|
private mapBookSelect(row: typeof books.$inferSelect, readState = unreadState) {
|
||||||
const { metadataProvenanceJson: _metadataProvenanceJson, ...book } = row;
|
const { metadataProvenanceJson: _metadataProvenanceJson, ...book } = row;
|
||||||
return {
|
return {
|
||||||
...book,
|
...book,
|
||||||
publishedDate: normalizePublishedDate(row.publishedDate),
|
publishedDate: normalizePublishedDate(row.publishedDate),
|
||||||
metadataProvenance: parseObject(row.metadataProvenanceJson),
|
metadataProvenance: parseObject(row.metadataProvenanceJson),
|
||||||
series: row.seriesId ? this.getSeriesRecord(row.seriesId) : null
|
series: row.seriesId ? this.getSeriesRecord(row.seriesId) : null,
|
||||||
|
...readState
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSeriesRecord(id: number) {
|
private getSeriesRecord(id: number) {
|
||||||
return this.database.db.select().from(series).where(eq(series.id, id)).get() ?? null;
|
return this.database.db.select().from(series).where(eq(series.id, id)).get() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readStatesFor(userId: number | undefined, bookIds: number[]): Map<number, ReadState> {
|
||||||
|
if (userId === undefined || bookIds.length === 0) return new Map();
|
||||||
|
const rows = this.database.db
|
||||||
|
.select({
|
||||||
|
bookId: progress.bookId,
|
||||||
|
readStatus: progress.readStatus,
|
||||||
|
completedAt: progress.completedAt,
|
||||||
|
readStatusSource: progress.readStatusSource
|
||||||
|
})
|
||||||
|
.from(progress)
|
||||||
|
.where(and(eq(progress.userId, userId), inArray(progress.bookId, bookIds)))
|
||||||
|
.all();
|
||||||
|
return new Map(
|
||||||
|
rows.map((row) => [
|
||||||
|
row.bookId,
|
||||||
|
{
|
||||||
|
readStatus: readStatusValue(row.readStatus),
|
||||||
|
completedAt: row.completedAt,
|
||||||
|
readStatusSource: readStatusSourceValue(row.readStatusSource)
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } {
|
function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } {
|
||||||
@ -254,6 +297,14 @@ function metadataStatusValue(value: unknown): "enriched" | "partial" | "none" {
|
|||||||
return value === "enriched" || value === "partial" ? value : "none";
|
return value === "enriched" || value === "partial" ? value : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStatusValue(value: unknown): "unread" | "reading" | "read" {
|
||||||
|
return value === "reading" || value === "read" ? value : "unread";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStatusSourceValue(value: unknown): "progress" | "manual" | null {
|
||||||
|
return value === "progress" || value === "manual" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
function parseObject(value: unknown): Record<string, string> {
|
function parseObject(value: unknown): Record<string, string> {
|
||||||
if (typeof value !== "string") return {};
|
if (typeof value !== "string") return {};
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
|
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
|
||||||
import { AuthGuard } from "../auth/auth.guard.js";
|
import { AuthGuard } from "../auth/auth.guard.js";
|
||||||
|
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
|
||||||
import { BooksService } from "./books.service.js";
|
import { BooksService } from "./books.service.js";
|
||||||
|
|
||||||
@Controller("series")
|
@Controller("series")
|
||||||
@ -13,7 +14,7 @@ export class SeriesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
get(@Param("id") id: string) {
|
get(@CurrentUserParam() user: CurrentUser, @Param("id") id: string) {
|
||||||
return this.books.getSeries(Number(id));
|
return this.books.getSeries(Number(id), user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -89,6 +89,8 @@ describe("database migrations", () => {
|
|||||||
|
|
||||||
INSERT INTO libraries (id, name, path, enabled, created_at, updated_at)
|
INSERT INTO libraries (id, name, path, enabled, created_at, updated_at)
|
||||||
VALUES (1, 'Corpus', '/library', 1, '${now}', '${now}');
|
VALUES (1, 'Corpus', '/library', 1, '${now}', '${now}');
|
||||||
|
INSERT INTO users (id, email, name, password_hash, role, created_at, updated_at)
|
||||||
|
VALUES (1, 'reader@example.com', 'Reader', 'hash', 'user', '${now}', '${now}');
|
||||||
INSERT INTO books (
|
INSERT INTO books (
|
||||||
library_id, title, author, description, isbn, language, publisher, published_date,
|
library_id, title, author, description, isbn, language, publisher, published_date,
|
||||||
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||||
@ -97,6 +99,8 @@ describe("database migrations", () => {
|
|||||||
(1, 'Solo Leveling T03', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Solo Leveling T03.cbz', NULL, 42, '${now}', '${now}', '${now}'),
|
(1, 'Solo Leveling T03', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Solo Leveling T03.cbz', NULL, 42, '${now}', '${now}', '${now}'),
|
||||||
(1, 'Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz', NULL, 42, '${now}', '${now}', '${now}'),
|
(1, 'Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz', NULL, 42, '${now}', '${now}', '${now}'),
|
||||||
(1, 'Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz', NULL, 42, '${now}', '${now}', '${now}');
|
(1, 'Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz', NULL, 42, '${now}', '${now}', '${now}');
|
||||||
|
INSERT INTO progress (user_id, book_id, locator, percent, created_at, updated_at)
|
||||||
|
VALUES (1, 1, 'epubcfi(/6/2)', 97, '${now}', '${now}');
|
||||||
`);
|
`);
|
||||||
legacy.close();
|
legacy.close();
|
||||||
|
|
||||||
@ -105,6 +109,7 @@ describe("database migrations", () => {
|
|||||||
|
|
||||||
const database = new DatabaseService();
|
const database = new DatabaseService();
|
||||||
const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
|
const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
|
||||||
|
const progressColumns = database.sqlite.prepare("PRAGMA table_info(progress)").all() as Array<{ name: string }>;
|
||||||
const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>;
|
const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>;
|
||||||
const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>;
|
const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>;
|
||||||
const seriesRows = database.sqlite
|
const seriesRows = database.sqlite
|
||||||
@ -126,6 +131,9 @@ describe("database migrations", () => {
|
|||||||
const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as
|
const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as
|
||||||
| { id: number; isbn_priority_enabled: number }
|
| { id: number; isbn_priority_enabled: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
const progressRow = database.sqlite
|
||||||
|
.prepare("SELECT read_status, completed_at, read_status_source FROM progress WHERE user_id = 1 AND book_id = 1")
|
||||||
|
.get() as { read_status: string; completed_at: string | null; read_status_source: string | null };
|
||||||
|
|
||||||
expect(bookColumns.map((column) => column.name)).toContain("isbn13");
|
expect(bookColumns.map((column) => column.name)).toContain("isbn13");
|
||||||
expect(bookColumns.map((column) => column.name)).toContain("identifiers_json");
|
expect(bookColumns.map((column) => column.name)).toContain("identifiers_json");
|
||||||
@ -134,6 +142,9 @@ describe("database migrations", () => {
|
|||||||
expect(bookColumns.map((column) => column.name)).toContain("series_id");
|
expect(bookColumns.map((column) => column.name)).toContain("series_id");
|
||||||
expect(bookColumns.map((column) => column.name)).toContain("volume_number");
|
expect(bookColumns.map((column) => column.name)).toContain("volume_number");
|
||||||
expect(bookColumns.map((column) => column.name)).toContain("volume_label");
|
expect(bookColumns.map((column) => column.name)).toContain("volume_label");
|
||||||
|
expect(progressColumns.map((column) => column.name)).toContain("read_status");
|
||||||
|
expect(progressColumns.map((column) => column.name)).toContain("completed_at");
|
||||||
|
expect(progressColumns.map((column) => column.name)).toContain("read_status_source");
|
||||||
expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx");
|
expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx");
|
||||||
expect(bookIndexes.map((index) => index.name)).toContain("books_series_idx");
|
expect(bookIndexes.map((index) => index.name)).toContain("books_series_idx");
|
||||||
expect(seriesRows).toEqual([
|
expect(seriesRows).toEqual([
|
||||||
@ -161,6 +172,7 @@ describe("database migrations", () => {
|
|||||||
]);
|
]);
|
||||||
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]);
|
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]);
|
||||||
expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 });
|
expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 });
|
||||||
|
expect(progressRow).toEqual({ read_status: "read", completed_at: now, read_status_source: "progress" });
|
||||||
|
|
||||||
database.onModuleDestroy();
|
database.onModuleDestroy();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -95,6 +95,9 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
||||||
locator TEXT NOT NULL,
|
locator TEXT NOT NULL,
|
||||||
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
|
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
|
||||||
|
read_status TEXT NOT NULL DEFAULT 'unread' CHECK (read_status IN ('unread','reading','read')),
|
||||||
|
completed_at TEXT,
|
||||||
|
read_status_source TEXT CHECK (read_status_source IN ('progress','manual')),
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
UNIQUE(user_id, book_id)
|
UNIQUE(user_id, book_id)
|
||||||
@ -165,6 +168,7 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
this.ensureBooksMetadataColumns();
|
this.ensureBooksMetadataColumns();
|
||||||
this.ensureSeriesModel();
|
this.ensureSeriesModel();
|
||||||
this.ensureReaderPreferencesTable();
|
this.ensureReaderPreferencesTable();
|
||||||
|
this.ensureProgressReadStatusColumns();
|
||||||
this.ensureMetadataSourceConfigSupportsComicProviders();
|
this.ensureMetadataSourceConfigSupportsComicProviders();
|
||||||
this.ensureMetadataSourceConfigColumns();
|
this.ensureMetadataSourceConfigColumns();
|
||||||
this.ensureAutomationSettingsColumns();
|
this.ensureAutomationSettingsColumns();
|
||||||
@ -375,6 +379,35 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureProgressReadStatusColumns(): void {
|
||||||
|
const names = this.columnNames("progress");
|
||||||
|
if (!names.has("read_status")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE progress ADD COLUMN read_status TEXT NOT NULL DEFAULT 'unread'");
|
||||||
|
}
|
||||||
|
if (!names.has("completed_at")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE progress ADD COLUMN completed_at TEXT");
|
||||||
|
}
|
||||||
|
if (!names.has("read_status_source")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE progress ADD COLUMN read_status_source TEXT");
|
||||||
|
}
|
||||||
|
this.sqlite.exec(`
|
||||||
|
UPDATE progress
|
||||||
|
SET
|
||||||
|
read_status = CASE
|
||||||
|
WHEN percent >= 95 THEN 'read'
|
||||||
|
WHEN percent > 0 THEN 'reading'
|
||||||
|
ELSE 'unread'
|
||||||
|
END,
|
||||||
|
completed_at = CASE
|
||||||
|
WHEN percent >= 95 THEN COALESCE(completed_at, updated_at)
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
read_status_source = COALESCE(read_status_source, 'progress')
|
||||||
|
WHERE read_status_source IS NULL
|
||||||
|
`);
|
||||||
|
this.sqlite.exec("CREATE INDEX IF NOT EXISTS progress_user_status_idx ON progress(user_id, read_status)");
|
||||||
|
}
|
||||||
|
|
||||||
private ensureMetadataSourceConfigColumns(): void {
|
private ensureMetadataSourceConfigColumns(): void {
|
||||||
const names = this.columnNames("metadata_source_config");
|
const names = this.columnNames("metadata_source_config");
|
||||||
const now = sqlString(this.now());
|
const now = sqlString(this.now());
|
||||||
|
|||||||
@ -102,6 +102,9 @@ export const progress = sqliteTable(
|
|||||||
.references(() => books.id, { onDelete: "cascade" }),
|
.references(() => books.id, { onDelete: "cascade" }),
|
||||||
locator: text("locator").notNull(),
|
locator: text("locator").notNull(),
|
||||||
percent: integer("percent").notNull(),
|
percent: integer("percent").notNull(),
|
||||||
|
readStatus: text("read_status", { enum: ["unread", "reading", "read"] }).notNull().default("unread"),
|
||||||
|
completedAt: text("completed_at"),
|
||||||
|
readStatusSource: text("read_status_source", { enum: ["progress", "manual"] }),
|
||||||
createdAt: text("created_at").notNull(),
|
createdAt: text("created_at").notNull(),
|
||||||
updatedAt: text("updated_at").notNull()
|
updatedAt: text("updated_at").notNull()
|
||||||
},
|
},
|
||||||
|
|||||||
29
apps/api/src/progress/progress.controller.test.ts
Normal file
29
apps/api/src/progress/progress.controller.test.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { ProgressController } from "./progress.controller.js";
|
||||||
|
|
||||||
|
describe("ProgressController read status endpoint", () => {
|
||||||
|
it("exposes PUT /progress/:bookId/status and delegates manual read status changes", () => {
|
||||||
|
const source = readFileSync(new URL("./progress.controller.ts", import.meta.url), "utf8");
|
||||||
|
const markReadStatus = vi.fn().mockReturnValue({
|
||||||
|
bookId: 42,
|
||||||
|
locator: "manual:read",
|
||||||
|
percent: 100,
|
||||||
|
readStatus: "read",
|
||||||
|
completedAt: "2026-08-30T00:00:00.000Z",
|
||||||
|
readStatusSource: "manual",
|
||||||
|
updatedAt: "2026-08-30T00:00:00.000Z"
|
||||||
|
});
|
||||||
|
const controller = new ProgressController({ markReadStatus } as never);
|
||||||
|
|
||||||
|
expect(source).toContain('@Put(":bookId/status")');
|
||||||
|
expect(source).toContain("new ZodValidationPipe(UpdateReadStatusSchema)");
|
||||||
|
|
||||||
|
expect(controller.updateStatus({ id: 7, email: "reader@example.com", role: "user" }, "42", { status: "read" })).toMatchObject({
|
||||||
|
bookId: 42,
|
||||||
|
readStatus: "read",
|
||||||
|
readStatusSource: "manual"
|
||||||
|
});
|
||||||
|
expect(markReadStatus).toHaveBeenCalledWith(7, 42, { status: "read" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
|
||||||
import { UpdateProgressDto, UpdateProgressSchema } from "@readabook/shared";
|
import { UpdateProgressDto, UpdateProgressSchema, UpdateReadStatusDto, UpdateReadStatusSchema } from "@readabook/shared";
|
||||||
import { AuthGuard } from "../auth/auth.guard.js";
|
import { AuthGuard } from "../auth/auth.guard.js";
|
||||||
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
|
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
|
||||||
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
|
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
|
||||||
@ -28,4 +28,13 @@ export class ProgressController {
|
|||||||
) {
|
) {
|
||||||
return this.progress.upsert(user.id, Number(bookId), body);
|
return this.progress.upsert(user.id, Number(bookId), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(":bookId/status")
|
||||||
|
updateStatus(
|
||||||
|
@CurrentUserParam() user: CurrentUser,
|
||||||
|
@Param("bookId") bookId: string,
|
||||||
|
@Body(new ZodValidationPipe(UpdateReadStatusSchema)) body: UpdateReadStatusDto
|
||||||
|
) {
|
||||||
|
return this.progress.markReadStatus(user.id, Number(bookId), body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
158
apps/api/src/progress/progress.service.test.ts
Normal file
158
apps/api/src/progress/progress.service.test.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { DatabaseService } from "../database/database.service.js";
|
||||||
|
import { books, libraries, users } from "../database/schema.js";
|
||||||
|
import { ProgressService } from "./progress.service.js";
|
||||||
|
|
||||||
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
const previousStorageDir = process.env.STORAGE_DIR;
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.DATABASE_PATH = previousDatabasePath;
|
||||||
|
process.env.STORAGE_DIR = previousStorageDir;
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProgressService", () => {
|
||||||
|
it.runIf(canLoadBetterSqlite())("marks a book read automatically when progress reaches the completion threshold", () => {
|
||||||
|
const { database, service, userId, bookId } = createService();
|
||||||
|
try {
|
||||||
|
const result = service.upsert(userId, bookId, { locator: "epubcfi(/6/2)", percent: 95 });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
bookId,
|
||||||
|
locator: "epubcfi(/6/2)",
|
||||||
|
percent: 95,
|
||||||
|
readStatus: "read",
|
||||||
|
readStatusSource: "progress"
|
||||||
|
});
|
||||||
|
expect(result.completedAt).toEqual(expect.any(String));
|
||||||
|
expect(service.continueReading(userId)).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
database.onModuleDestroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.runIf(canLoadBetterSqlite())("keeps reading progress below the completion threshold in continue reading", () => {
|
||||||
|
const { database, service, userId, bookId } = createService();
|
||||||
|
try {
|
||||||
|
service.upsert(userId, bookId, { locator: "epubcfi(/6/2)", percent: 42 });
|
||||||
|
|
||||||
|
expect(service.get(userId, bookId)).toMatchObject({
|
||||||
|
percent: 42,
|
||||||
|
readStatus: "reading",
|
||||||
|
completedAt: null,
|
||||||
|
readStatusSource: "progress"
|
||||||
|
});
|
||||||
|
expect(service.continueReading(userId)).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
database.onModuleDestroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.runIf(canLoadBetterSqlite())("allows manual read and unread status changes", () => {
|
||||||
|
const { database, service, userId, bookId } = createService();
|
||||||
|
try {
|
||||||
|
const read = service.markReadStatus(userId, bookId, { status: "read" });
|
||||||
|
|
||||||
|
expect(read).toMatchObject({
|
||||||
|
locator: "manual:read",
|
||||||
|
percent: 100,
|
||||||
|
readStatus: "read",
|
||||||
|
readStatusSource: "manual"
|
||||||
|
});
|
||||||
|
expect(read.completedAt).toEqual(expect.any(String));
|
||||||
|
|
||||||
|
const unread = service.markReadStatus(userId, bookId, { status: "unread" });
|
||||||
|
|
||||||
|
expect(unread).toMatchObject({
|
||||||
|
locator: "manual:unread",
|
||||||
|
percent: 0,
|
||||||
|
readStatus: "unread",
|
||||||
|
completedAt: null,
|
||||||
|
readStatusSource: "manual"
|
||||||
|
});
|
||||||
|
expect(service.continueReading(userId)).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
database.onModuleDestroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createService(): { database: DatabaseService; service: ProgressService; userId: number; bookId: number } {
|
||||||
|
const database = createDatabase();
|
||||||
|
const service = new ProgressService(database);
|
||||||
|
const now = database.now();
|
||||||
|
const user = database.db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
email: "reader@example.com",
|
||||||
|
name: "Reader",
|
||||||
|
passwordHash: "hash",
|
||||||
|
role: "user",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
const library = database.db
|
||||||
|
.insert(libraries)
|
||||||
|
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
const book = database.db
|
||||||
|
.insert(books)
|
||||||
|
.values({
|
||||||
|
libraryId: library.id,
|
||||||
|
seriesId: null,
|
||||||
|
title: "Daredevil",
|
||||||
|
author: null,
|
||||||
|
description: null,
|
||||||
|
isbn: null,
|
||||||
|
isbn13: null,
|
||||||
|
identifiersJson: null,
|
||||||
|
localMetadataJson: null,
|
||||||
|
language: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedDate: null,
|
||||||
|
volumeNumber: null,
|
||||||
|
volumeLabel: null,
|
||||||
|
format: "cbz",
|
||||||
|
filePath: "/library/Daredevil.cbz",
|
||||||
|
coverPath: null,
|
||||||
|
metadataStatus: "none",
|
||||||
|
metadataProvenanceJson: null,
|
||||||
|
scanStatus: "succeeded",
|
||||||
|
enrichmentStatus: "idle",
|
||||||
|
fileSize: 42,
|
||||||
|
fileMtime: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
return { database, service, userId: user.id, bookId: book.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDatabase(): DatabaseService {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "readabook-progress-service-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
|
||||||
|
process.env.STORAGE_DIR = join(dir, "storage");
|
||||||
|
return new DatabaseService();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canLoadBetterSqlite(): boolean {
|
||||||
|
try {
|
||||||
|
new Database(":memory:").close();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,31 +1,63 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { desc, eq, sql } from "drizzle-orm";
|
import { desc, eq, sql } from "drizzle-orm";
|
||||||
import { UpdateProgressDto } from "@readabook/shared";
|
import { UpdateProgressDto, UpdateReadStatusDto } from "@readabook/shared";
|
||||||
import { DatabaseService } from "../database/database.service.js";
|
import { DatabaseService } from "../database/database.service.js";
|
||||||
import { books, progress } from "../database/schema.js";
|
import { books, progress } from "../database/schema.js";
|
||||||
|
|
||||||
|
const READ_COMPLETION_THRESHOLD = 95;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProgressService {
|
export class ProgressService {
|
||||||
constructor(private readonly database: DatabaseService) {}
|
constructor(private readonly database: DatabaseService) {}
|
||||||
|
|
||||||
upsert(userId: number, bookId: number, input: UpdateProgressDto) {
|
upsert(userId: number, bookId: number, input: UpdateProgressDto) {
|
||||||
const book = this.database.db.select({ id: books.id }).from(books).where(eq(books.id, bookId)).get();
|
this.assertBookExists(bookId);
|
||||||
if (!book) {
|
|
||||||
throw new NotFoundException("Book not found");
|
|
||||||
}
|
|
||||||
const now = this.database.now();
|
const now = this.database.now();
|
||||||
|
const percent = Math.round(input.percent);
|
||||||
|
const current = this.find(userId, bookId);
|
||||||
|
const readStatus = statusFromProgressPercent(percent);
|
||||||
|
const completedAt = readStatus === "read" ? current?.completedAt ?? now : null;
|
||||||
this.database.sqlite
|
this.database.sqlite
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO progress(user_id, book_id, locator, percent, created_at, updated_at)
|
INSERT INTO progress(user_id, book_id, locator, percent, read_status, completed_at, read_status_source, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, 'progress', ?, ?)
|
||||||
ON CONFLICT(user_id, book_id) DO UPDATE SET
|
ON CONFLICT(user_id, book_id) DO UPDATE SET
|
||||||
locator = excluded.locator,
|
locator = excluded.locator,
|
||||||
percent = excluded.percent,
|
percent = excluded.percent,
|
||||||
|
read_status = excluded.read_status,
|
||||||
|
completed_at = excluded.completed_at,
|
||||||
|
read_status_source = excluded.read_status_source,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
.run(userId, bookId, input.locator, Math.round(input.percent), now, now);
|
.run(userId, bookId, input.locator, percent, readStatus, completedAt, now, now);
|
||||||
|
return this.get(userId, bookId);
|
||||||
|
}
|
||||||
|
|
||||||
|
markReadStatus(userId: number, bookId: number, input: UpdateReadStatusDto) {
|
||||||
|
this.assertBookExists(bookId);
|
||||||
|
const now = this.database.now();
|
||||||
|
const current = this.find(userId, bookId);
|
||||||
|
const status = input.status;
|
||||||
|
const percent = status === "read" ? 100 : 0;
|
||||||
|
const locator = status === "read" ? current?.locator ?? "manual:read" : "manual:unread";
|
||||||
|
const completedAt = status === "read" ? current?.completedAt ?? now : null;
|
||||||
|
this.database.sqlite
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO progress(user_id, book_id, locator, percent, read_status, completed_at, read_status_source, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'manual', ?, ?)
|
||||||
|
ON CONFLICT(user_id, book_id) DO UPDATE SET
|
||||||
|
locator = excluded.locator,
|
||||||
|
percent = excluded.percent,
|
||||||
|
read_status = excluded.read_status,
|
||||||
|
completed_at = excluded.completed_at,
|
||||||
|
read_status_source = excluded.read_status_source,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.run(userId, bookId, locator, percent, status, completedAt, now, now);
|
||||||
return this.get(userId, bookId);
|
return this.get(userId, bookId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,6 +75,9 @@ export class ProgressService {
|
|||||||
bookId: progress.bookId,
|
bookId: progress.bookId,
|
||||||
locator: progress.locator,
|
locator: progress.locator,
|
||||||
percent: progress.percent,
|
percent: progress.percent,
|
||||||
|
readStatus: progress.readStatus,
|
||||||
|
completedAt: progress.completedAt,
|
||||||
|
readStatusSource: progress.readStatusSource,
|
||||||
updatedAt: progress.updatedAt
|
updatedAt: progress.updatedAt
|
||||||
})
|
})
|
||||||
.from(progress)
|
.from(progress)
|
||||||
@ -59,14 +94,30 @@ export class ProgressService {
|
|||||||
bookId: progress.bookId,
|
bookId: progress.bookId,
|
||||||
locator: progress.locator,
|
locator: progress.locator,
|
||||||
percent: progress.percent,
|
percent: progress.percent,
|
||||||
|
readStatus: progress.readStatus,
|
||||||
|
completedAt: progress.completedAt,
|
||||||
|
readStatusSource: progress.readStatusSource,
|
||||||
updatedAt: progress.updatedAt
|
updatedAt: progress.updatedAt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.from(progress)
|
.from(progress)
|
||||||
.innerJoin(books, eq(progress.bookId, books.id))
|
.innerJoin(books, eq(progress.bookId, books.id))
|
||||||
.where(eq(progress.userId, userId))
|
.where(sql`${progress.userId} = ${userId} AND ${progress.readStatus} = 'reading'`)
|
||||||
.orderBy(desc(progress.updatedAt))
|
.orderBy(desc(progress.updatedAt))
|
||||||
.limit(20)
|
.limit(20)
|
||||||
.all();
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertBookExists(bookId: number): void {
|
||||||
|
const book = this.database.db.select({ id: books.id }).from(books).where(eq(books.id, bookId)).get();
|
||||||
|
if (!book) {
|
||||||
|
throw new NotFoundException("Book not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFromProgressPercent(percent: number): "unread" | "reading" | "read" {
|
||||||
|
if (percent >= READ_COMPLETION_THRESHOLD) return "read";
|
||||||
|
if (percent > 0) return "reading";
|
||||||
|
return "unread";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
};
|
||||||
@ -17,6 +17,15 @@ RUN pnpm --filter @readabook/shared build
|
|||||||
RUN pnpm --filter @readabook/web build
|
RUN pnpm --filter @readabook/web build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine AS runtime
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
ARG OCI_IMAGE_CREATED
|
||||||
|
ARG OCI_IMAGE_REVISION
|
||||||
|
ARG OCI_IMAGE_VERSION=latest
|
||||||
|
LABEL org.opencontainers.image.title="ReadaBook Web" \
|
||||||
|
org.opencontainers.image.description="ReadaBook PWA served by Nginx" \
|
||||||
|
org.opencontainers.image.source="https://gitea.anthonybouteiller.ovh/blomios/ReadaBook" \
|
||||||
|
org.opencontainers.image.created="${OCI_IMAGE_CREATED}" \
|
||||||
|
org.opencontainers.image.revision="${OCI_IMAGE_REVISION}" \
|
||||||
|
org.opencontainers.image.version="${OCI_IMAGE_VERSION}"
|
||||||
COPY apps/web/nginx/default.conf /etc/nginx/conf.d/default.conf
|
COPY apps/web/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@ -59,6 +59,27 @@ describe("api fallback helpers", () => {
|
|||||||
expect(headers.has("Content-Type")).toBe(false);
|
expect(headers.has("Content-Type")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends the adaptive reading window with the selected network policy", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ bookId: 12, locator: "chapter-2", current: null, previous: null, next: null, neighbors: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await api.readingWindow(12, "chapter-2", "speed", "slow");
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe("/books/12/reading-window");
|
||||||
|
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ locator: "chapter-2", mode: "speed", networkHint: "slow" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds an encoded reading resource URL", () => {
|
||||||
|
expect(api.readingResourceUrl(12, "chapter/one")).toBe("/books/12/reading-resources/chapter%2Fone");
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces create library API errors without fallback", async () => {
|
it("surfaces create library API errors without fallback", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
new Response(JSON.stringify({ message: "Library path does not exist" }), {
|
new Response(JSON.stringify({ message: "Library path does not exist" }), {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import type {
|
|||||||
UpdateAccountDto,
|
UpdateAccountDto,
|
||||||
UpdateMetadataSourcesConfigDto,
|
UpdateMetadataSourcesConfigDto,
|
||||||
UpdateProgressDto,
|
UpdateProgressDto,
|
||||||
|
UpdateReadStatusDto,
|
||||||
UserDto
|
UserDto
|
||||||
} from "@readabook/shared";
|
} from "@readabook/shared";
|
||||||
import {
|
import {
|
||||||
@ -26,7 +27,16 @@ import {
|
|||||||
mockProgress,
|
mockProgress,
|
||||||
mockUser
|
mockUser
|
||||||
} from "./mockData";
|
} from "./mockData";
|
||||||
import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types";
|
import type {
|
||||||
|
CbzPagesDto,
|
||||||
|
ContinueItem,
|
||||||
|
ReaderPreferencesDto,
|
||||||
|
ReadingConnectionMode,
|
||||||
|
ReadingManifestDto,
|
||||||
|
ReadingNetworkHint,
|
||||||
|
ReadingWindowDto,
|
||||||
|
Session
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
|
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
|
||||||
@ -178,6 +188,21 @@ export const api = {
|
|||||||
bookCoverUrl(id: number): string {
|
bookCoverUrl(id: number): string {
|
||||||
return `${API_BASE}/books/${id}/cover`;
|
return `${API_BASE}/books/${id}/cover`;
|
||||||
},
|
},
|
||||||
|
async readingManifest(id: number): Promise<ReadingManifestDto> {
|
||||||
|
return request<ReadingManifestDto>(`/books/${id}/reading-manifest`);
|
||||||
|
},
|
||||||
|
readingResourceUrl(id: number, resourceId: string): string {
|
||||||
|
return `${API_BASE}/books/${id}/reading-resources/${encodeURIComponent(resourceId)}`;
|
||||||
|
},
|
||||||
|
async readingWindow(id: number, locator: string, mode: ReadingConnectionMode, networkHint: ReadingNetworkHint): Promise<ReadingWindowDto> {
|
||||||
|
return request<ReadingWindowDto>(`/books/${id}/reading-window`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ locator, mode, networkHint })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async retryReadingResource(id: number, resourceId: string): Promise<void> {
|
||||||
|
await request(`/books/${id}/reading-resources/${encodeURIComponent(resourceId)}/retry`, { method: "POST" });
|
||||||
|
},
|
||||||
async cbzPages(id: number): Promise<CbzPagesDto> {
|
async cbzPages(id: number): Promise<CbzPagesDto> {
|
||||||
return request<CbzPagesDto>(`/books/${id}/pages`);
|
return request<CbzPagesDto>(`/books/${id}/pages`);
|
||||||
},
|
},
|
||||||
@ -198,7 +223,30 @@ export const api = {
|
|||||||
return request<ProgressDto>(`/progress/${bookId}`, {
|
return request<ProgressDto>(`/progress/${bookId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
|
fallback: {
|
||||||
|
bookId,
|
||||||
|
...input,
|
||||||
|
readStatus: input.percent >= 95 ? "read" : input.percent > 0 ? "reading" : "unread",
|
||||||
|
completedAt: input.percent >= 95 ? new Date().toISOString() : null,
|
||||||
|
readStatusSource: "progress",
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async markReadStatus(bookId: number, input: UpdateReadStatusDto): Promise<ProgressDto> {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
return request<ProgressDto>(`/progress/${bookId}/status`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
fallback: {
|
||||||
|
bookId,
|
||||||
|
locator: input.status === "read" ? "manual:read" : "manual:unread",
|
||||||
|
percent: input.status === "read" ? 100 : 0,
|
||||||
|
readStatus: input.status,
|
||||||
|
completedAt: input.status === "read" ? updatedAt : null,
|
||||||
|
readStatusSource: "manual",
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async readerPreferences(bookId: number): Promise<ReaderPreferencesDto> {
|
async readerPreferences(bookId: number): Promise<ReaderPreferencesDto> {
|
||||||
|
|||||||
@ -32,6 +32,63 @@ export type ReaderMode = "horizontal" | "vertical";
|
|||||||
|
|
||||||
export type ReaderFit = "page" | "width";
|
export type ReaderFit = "page" | "width";
|
||||||
|
|
||||||
|
export type ReadingConnectionMode = "auto" | "speed" | "quality";
|
||||||
|
export type ReadingNetworkHint = "offline" | "slow" | "normal" | "fast" | "unknown";
|
||||||
|
|
||||||
|
export type ReadingResourceDto = {
|
||||||
|
resourceId: string;
|
||||||
|
href: string;
|
||||||
|
url: string;
|
||||||
|
kind: "chapter" | "image" | "stylesheet" | "font" | "document";
|
||||||
|
mediaType: string;
|
||||||
|
size: number;
|
||||||
|
state: "ready" | "pending" | "unsupported" | "failed";
|
||||||
|
references?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadingSpineItemDto = {
|
||||||
|
index: number;
|
||||||
|
href: string;
|
||||||
|
resourceId: string;
|
||||||
|
url: string;
|
||||||
|
mediaType: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadingManifestDto = {
|
||||||
|
bookId: number;
|
||||||
|
format: "epub" | "pdf";
|
||||||
|
revision: string;
|
||||||
|
version?: string;
|
||||||
|
containerPath: string | null;
|
||||||
|
opfPath: string | null;
|
||||||
|
basePath: string | null;
|
||||||
|
resources: ReadingResourceDto[];
|
||||||
|
spine: ReadingSpineItemDto[];
|
||||||
|
capabilities: {
|
||||||
|
byteRanges: boolean;
|
||||||
|
derivedImages: boolean;
|
||||||
|
lightweightPages: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadingWindowItemDto = {
|
||||||
|
resourceId: string;
|
||||||
|
relation: "current" | "previous" | "next" | "neighbor";
|
||||||
|
state: ReadingResourceDto["state"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReadingWindowDto = {
|
||||||
|
bookId: number;
|
||||||
|
locator: string;
|
||||||
|
mode: ReadingConnectionMode;
|
||||||
|
networkHint: ReadingNetworkHint;
|
||||||
|
current: ReadingWindowItemDto | null;
|
||||||
|
previous: ReadingWindowItemDto | null;
|
||||||
|
next: ReadingWindowItemDto | null;
|
||||||
|
neighbors: ReadingWindowItemDto[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ReaderPreferencesDto = {
|
export type ReaderPreferencesDto = {
|
||||||
mode: ReaderMode;
|
mode: ReaderMode;
|
||||||
fit?: ReaderFit;
|
fit?: ReaderFit;
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { BookDto } from "@readabook/shared";
|
|||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { bookCardVolumeLabel, bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, displayPublishedDate } from "../book/metadata";
|
import { bookCardVolumeLabel, bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, displayPublishedDate } from "../book/metadata";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { FormatPill } from "./ui";
|
import { FormatPill, ReadStatusBadge } from "./ui";
|
||||||
|
|
||||||
export function BookCard({ book, compact = false, coverLoading = false }: { book: BookDto; compact?: boolean; coverLoading?: boolean }) {
|
export function BookCard({ book, compact = false, coverLoading = false }: { book: BookDto; compact?: boolean; coverLoading?: boolean }) {
|
||||||
const metadataState = bookMetadataState(book);
|
const metadataState = bookMetadataState(book);
|
||||||
@ -19,6 +19,7 @@ export function BookCard({ book, compact = false, coverLoading = false }: { book
|
|||||||
<div className="book-card-body">
|
<div className="book-card-body">
|
||||||
<div className="book-card-meta">
|
<div className="book-card-meta">
|
||||||
<FormatPill format={book.format} />
|
<FormatPill format={book.format} />
|
||||||
|
<ReadStatusBadge readStatus={book.readStatus} />
|
||||||
{volumeLabel && <span className="volume-pill">{volumeLabel}</span>}
|
{volumeLabel && <span className="volume-pill">{volumeLabel}</span>}
|
||||||
<span className="book-card-language">{book.language ?? "langue inconnue"}</span>
|
<span className="book-card-language">{book.language ?? "langue inconnue"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import type { ReadStatus } from "@readabook/shared";
|
||||||
|
|
||||||
export function Panel({ children, className = "" }: { children: ReactNode; className?: string }) {
|
export function Panel({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||||
return <section className={`panel ${className}`}>{children}</section>;
|
return <section className={`panel ${className}`}>{children}</section>;
|
||||||
@ -32,6 +34,16 @@ export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" | "cbr"
|
|||||||
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
|
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ReadStatusBadge({ readStatus }: { readStatus?: ReadStatus }) {
|
||||||
|
if (readStatus !== "read") return null;
|
||||||
|
return (
|
||||||
|
<span className="read-status-badge" aria-label="Livre lu">
|
||||||
|
<CheckCircle2 size={13} />
|
||||||
|
Lu
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Meter({ value }: { value: number }) {
|
export function Meter({ value }: { value: number }) {
|
||||||
return (
|
return (
|
||||||
<span className="meter" aria-label={`${Math.round(value)}%`}>
|
<span className="meter" aria-label={`${Math.round(value)}%`}>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
import { BookOpen, CheckCircle2, LibraryBig, RotateCcw } from "lucide-react";
|
||||||
import type { BookDto, ProgressDto } from "@readabook/shared";
|
import type { BookDto, ProgressDto } from "@readabook/shared";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { cleanBookDescription } from "../book/description";
|
import { cleanBookDescription } from "../book/description";
|
||||||
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
|
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
|
||||||
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel, ReadStatusBadge } from "../components/ui";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
|
|
||||||
export function BookPage({ bookId }: { bookId: number }) {
|
export function BookPage({ bookId }: { bookId: number }) {
|
||||||
@ -33,6 +33,27 @@ export function BookPage({ bookId }: { bookId: number }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markReadStatus(status: "read" | "unread") {
|
||||||
|
if (!book) return;
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const nextProgress = await api.markReadStatus(book.id, { status });
|
||||||
|
setProgress(nextProgress);
|
||||||
|
setBook((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
readStatus: nextProgress.readStatus,
|
||||||
|
completedAt: nextProgress.completedAt,
|
||||||
|
readStatusSource: nextProgress.readStatusSource
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setError(status === "read" ? "Marquage comme lu impossible." : "Marquage comme non lu impossible.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
loadBook().finally(() => {
|
loadBook().finally(() => {
|
||||||
@ -65,6 +86,7 @@ export function BookPage({ bookId }: { bookId: number }) {
|
|||||||
|
|
||||||
const series = bookSeriesInfo(book);
|
const series = bookSeriesInfo(book);
|
||||||
const metadataState = bookMetadataState(book);
|
const metadataState = bookMetadataState(book);
|
||||||
|
const readStatus = progress?.readStatus ?? book.readStatus;
|
||||||
const publishedDate = displayPublishedDate(book.publishedDate);
|
const publishedDate = displayPublishedDate(book.publishedDate);
|
||||||
const detailFacts = [
|
const detailFacts = [
|
||||||
{ label: "Auteur", value: book.author },
|
{ label: "Auteur", value: book.author },
|
||||||
@ -84,6 +106,7 @@ export function BookPage({ bookId }: { bookId: number }) {
|
|||||||
<ErrorRibbon message={error} />
|
<ErrorRibbon message={error} />
|
||||||
<div className="book-card-meta">
|
<div className="book-card-meta">
|
||||||
<FormatPill format={book.format} />
|
<FormatPill format={book.format} />
|
||||||
|
<ReadStatusBadge readStatus={readStatus} />
|
||||||
<span>{book.language ?? "langue inconnue"}</span>
|
<span>{book.language ?? "langue inconnue"}</span>
|
||||||
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
|
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -108,6 +131,10 @@ export function BookPage({ bookId }: { bookId: number }) {
|
|||||||
<LibraryBig size={18} />
|
<LibraryBig size={18} />
|
||||||
Rayon
|
Rayon
|
||||||
</button>
|
</button>
|
||||||
|
<button className="ghost-button read-status-action" onClick={() => void markReadStatus(readStatus === "read" ? "unread" : "read")}>
|
||||||
|
{readStatus === "read" ? <RotateCcw size={18} /> : <CheckCircle2 size={18} />}
|
||||||
|
{readStatus === "read" ? "Marquer comme non lu" : "Marquer comme lu"}
|
||||||
|
</button>
|
||||||
{series && (
|
{series && (
|
||||||
<button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}>
|
<button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}>
|
||||||
<LibraryBig size={18} />
|
<LibraryBig size={18} />
|
||||||
|
|||||||
41
apps/web/src/pages/BookReadStatus.test.ts
Normal file
41
apps/web/src/pages/BookReadStatus.test.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("book read status frontend wiring", () => {
|
||||||
|
it("uses the backend read status endpoint for manual book detail actions", () => {
|
||||||
|
const client = readFileSync(new URL("../api/client.ts", import.meta.url), "utf8");
|
||||||
|
const bookPage = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(client).toContain("markReadStatus");
|
||||||
|
expect(client).toContain("`/progress/${bookId}/status`");
|
||||||
|
expect(client).toContain("body: JSON.stringify(input)");
|
||||||
|
expect(bookPage).toContain('api.markReadStatus(book.id, { status })');
|
||||||
|
expect(bookPage).toContain("Marquer comme lu");
|
||||||
|
expect(bookPage).toContain("Marquer comme non lu");
|
||||||
|
expect(bookPage).toContain("setProgress(nextProgress)");
|
||||||
|
expect(bookPage).toContain("readStatus: nextProgress.readStatus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders read badges on catalogue cards, home cards and the book detail page", () => {
|
||||||
|
const ui = readFileSync(new URL("../components/ui.tsx", import.meta.url), "utf8");
|
||||||
|
const bookCard = readFileSync(new URL("../components/BookCard.tsx", import.meta.url), "utf8");
|
||||||
|
const homePage = readFileSync(new URL("./HomePage.tsx", import.meta.url), "utf8");
|
||||||
|
const bookPage = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8");
|
||||||
|
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(ui).toContain('readStatus !== "read"');
|
||||||
|
expect(ui).toContain('aria-label="Livre lu"');
|
||||||
|
expect(bookCard).toContain("<ReadStatusBadge readStatus={book.readStatus} />");
|
||||||
|
expect(homePage).toContain("<ReadStatusBadge readStatus={item.readStatus} />");
|
||||||
|
expect(bookPage).toContain("<ReadStatusBadge readStatus={readStatus} />");
|
||||||
|
expect(styles).toContain(".read-status-badge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps continue reading delegated to the API-updated reading list", () => {
|
||||||
|
const homePage = readFileSync(new URL("./HomePage.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(homePage).toContain("api.continueReading()");
|
||||||
|
expect(homePage).not.toContain('readStatus !== "read"');
|
||||||
|
expect(homePage).not.toContain('readStatus === "read"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,7 +3,7 @@ import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
|
|||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { DashboardData } from "../api/types";
|
import type { DashboardData } from "../api/types";
|
||||||
import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata";
|
import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata";
|
||||||
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
|
import { EmptyState, LoadingState, Meter, Panel, ReadStatusBadge } from "../components/ui";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
@ -39,6 +39,7 @@ export function HomePage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="home-book-copy">
|
<span className="home-book-copy">
|
||||||
{volumeLabel && <span className="home-book-volume">{volumeLabel}</span>}
|
{volumeLabel && <span className="home-book-volume">{volumeLabel}</span>}
|
||||||
|
<ReadStatusBadge readStatus={item.readStatus} />
|
||||||
<strong>{bookDisplayTitle(item)}</strong>
|
<strong>{bookDisplayTitle(item)}</strong>
|
||||||
<span>{item.author ?? "Auteur inconnu"}</span>
|
<span>{item.author ?? "Auteur inconnu"}</span>
|
||||||
{options.progressPercent !== undefined && <Meter value={options.progressPercent} />}
|
{options.progressPercent !== undefined && <Meter value={options.progressPercent} />}
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
|
|||||||
setEmail((current) => current || nextStatus.initialAdminEmail);
|
setEmail((current) => current || nextStatus.initialAdminEmail);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (alive) setError("Statut d'authentification indisponible.");
|
// Auth status only enriches the unauthenticated screen; login must remain usable without it.
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
|||||||
14
apps/web/src/pages/LoginPageAuthStatusUnavailable.test.ts
Normal file
14
apps/web/src/pages/LoginPageAuthStatusUnavailable.test.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("login page when auth status is unavailable", () => {
|
||||||
|
it("keeps the unauthenticated login screen usable without a blocking auth status error", () => {
|
||||||
|
const source = readFileSync(new URL("./LoginPage.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain("<form onSubmit={submit} className=\"stack-form\">");
|
||||||
|
expect(source).toContain('type="email" required');
|
||||||
|
expect(source).toContain('type="password" required');
|
||||||
|
expect(source).toContain('type="submit"');
|
||||||
|
expect(source).not.toContain("Statut d'authentification indisponible");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,6 +10,7 @@ import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reade
|
|||||||
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
|
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
|
||||||
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
||||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||||
|
import { useAdaptiveReading } from "../reader/useAdaptiveReading";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||||
|
|
||||||
@ -69,6 +70,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
||||||
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
|
||||||
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
|
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
|
||||||
|
const adaptive = useAdaptiveReading(bookId, book?.format === "epub" || book?.format === "pdf");
|
||||||
|
|
||||||
async function loadBook() {
|
async function loadBook() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -104,7 +106,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
||||||
}, [progress]);
|
}, [progress]);
|
||||||
|
|
||||||
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
|
const legacyFileUrl = useMemo(() => (book?.format === "epub" ? "" : api.bookFileUrl(bookId)), [book?.format, bookId]);
|
||||||
|
const adaptiveFileUrl = useMemo(() => {
|
||||||
|
if (book?.format !== "pdf" || !adaptive.manifest) return legacyFileUrl;
|
||||||
|
return adaptive.manifest.spine[0]?.url ?? api.readingResourceUrl(bookId, "original");
|
||||||
|
}, [adaptive.manifest, book?.format, bookId, legacyFileUrl]);
|
||||||
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
|
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
|
||||||
const savePdfPage = useCallback(
|
const savePdfPage = useCallback(
|
||||||
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
||||||
@ -113,10 +119,19 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
const percent = pdfPagePercent(nextPage, pages, anchor);
|
const percent = pdfPagePercent(nextPage, pages, anchor);
|
||||||
if (strategy === "queued") queueSave(locator, percent);
|
if (strategy === "queued") queueSave(locator, percent);
|
||||||
else void save(locator, percent);
|
else void save(locator, percent);
|
||||||
|
void adaptive.prioritize(adaptive.manifest?.spine[0]?.resourceId ?? "original");
|
||||||
},
|
},
|
||||||
[queueSave, save]
|
[adaptive.manifest?.spine, adaptive.prioritize, queueSave, save]
|
||||||
);
|
);
|
||||||
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
|
const saveEpubLocator = useCallback((locator: string, percent: number, readingLocator: string) => {
|
||||||
|
void save(locator, percent);
|
||||||
|
void adaptive.prioritize(readingLocator);
|
||||||
|
}, [adaptive.prioritize, save]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const first = adaptive.manifest?.spine[0]?.resourceId;
|
||||||
|
if (first) void adaptive.prioritize(first);
|
||||||
|
}, [adaptive.manifest?.revision, adaptive.mode]);
|
||||||
const saveComicPage = useCallback(
|
const saveComicPage = useCallback(
|
||||||
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
@ -129,7 +144,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
[book?.format, queueSave, save]
|
[book?.format, queueSave, save]
|
||||||
);
|
);
|
||||||
|
|
||||||
const readerError = error ?? progressError ?? preferencesError;
|
const readerError = error ?? progressError ?? preferencesError ?? adaptive.error;
|
||||||
const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr";
|
const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr";
|
||||||
const supportsMode = supportsZoom;
|
const supportsMode = supportsZoom;
|
||||||
const currentVisiblePage = useCallback(() => {
|
const currentVisiblePage = useCallback(() => {
|
||||||
@ -231,10 +246,12 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
title={book?.title ?? "Ouverture du lecteur"}
|
title={book?.title ?? "Ouverture du lecteur"}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
error={readerError}
|
error={readerError}
|
||||||
onRetry={error ? () => void loadBook() : undefined}
|
|
||||||
controls={readerControls}
|
controls={readerControls}
|
||||||
zoomControls={zoomControls}
|
zoomControls={zoomControls}
|
||||||
modeControls={modeControls}
|
modeControls={modeControls}
|
||||||
|
connectionControls={book.format === "epub" || book.format === "pdf" ? { mode: adaptive.mode, onModeChange: adaptive.setMode } : undefined}
|
||||||
|
activity={book.format === "epub" || book.format === "pdf" ? adaptive.statusLabel : undefined}
|
||||||
|
onRetry={error ? () => void loadBook() : adaptive.error ? adaptive.retry : undefined}
|
||||||
>
|
>
|
||||||
<ReaderCrashBoundary
|
<ReaderCrashBoundary
|
||||||
resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
|
resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
|
||||||
@ -273,7 +290,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
>
|
>
|
||||||
{book.format === "pdf" ? (
|
{book.format === "pdf" ? (
|
||||||
<PdfReader
|
<PdfReader
|
||||||
url={fileUrl}
|
url={adaptiveFileUrl}
|
||||||
page={page}
|
page={page}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
@ -290,15 +307,17 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
onPageCommit={saveComicPage}
|
onPageCommit={saveComicPage}
|
||||||
onControlsChange={setReaderControls}
|
onControlsChange={setReaderControls}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : adaptive.manifest?.format === "epub" ? (
|
||||||
<EpubReader
|
<EpubReader
|
||||||
url={fileUrl}
|
manifest={adaptive.manifest}
|
||||||
locator={progress?.locator}
|
locator={progress?.locator}
|
||||||
backHref={backHref}
|
backHref={backHref}
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
onLocatorChange={saveEpubLocator}
|
onLocatorChange={saveEpubLocator}
|
||||||
onControlsChange={setReaderControls}
|
onControlsChange={setReaderControls}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="reader-fallback"><span>Ouverture de la page…</span></div>
|
||||||
)}
|
)}
|
||||||
</ReaderCrashBoundary>
|
</ReaderCrashBoundary>
|
||||||
</ReaderShell>
|
</ReaderShell>
|
||||||
|
|||||||
@ -1,36 +1,60 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { ReadingManifestDto, ReaderMode } from "../api/types";
|
||||||
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
||||||
import type { ReaderControls } from "./ReaderShell";
|
import type { ReaderControls } from "./ReaderShell";
|
||||||
import type { ReaderMode } from "../api/types";
|
|
||||||
|
|
||||||
type FoliateLocation = {
|
|
||||||
cfi?: string;
|
|
||||||
fraction?: number;
|
|
||||||
current?: number;
|
|
||||||
total?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
type FoliateLocation = { cfi?: string; fraction?: number; current?: number; total?: number };
|
||||||
type FoliateView = HTMLElement & {
|
type FoliateView = HTMLElement & {
|
||||||
open(input: File | Blob | string): Promise<void>;
|
open(input: unknown): Promise<void>;
|
||||||
close(): void;
|
close(): void;
|
||||||
goLeft(): Promise<void>;
|
goLeft(): Promise<void>;
|
||||||
goRight(): Promise<void>;
|
goRight(): Promise<void>;
|
||||||
goTo(target: string): Promise<unknown>;
|
goTo(target: string): Promise<unknown>;
|
||||||
next(): Promise<void>;
|
next(): Promise<void>;
|
||||||
lastLocation?: FoliateLocation;
|
};
|
||||||
|
type EpubApiLoader = {
|
||||||
|
loadText(path: string): Promise<string | null>;
|
||||||
|
loadBlob(path: string): Promise<Blob | null>;
|
||||||
|
getSize(path: string): number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function epubFileName(url: string): string {
|
function normalizedResourcePath(path: string): string {
|
||||||
|
const withoutFragment = path.split("#", 1)[0].split("?", 1)[0];
|
||||||
try {
|
try {
|
||||||
const base = globalThis.location?.href ?? "http://readabook.local/";
|
return decodeURI(withoutFragment).replace(/^\.\//, "").replace(/^\/+/, "");
|
||||||
const pathname = new URL(url, base).pathname;
|
|
||||||
const name = pathname.split("/").filter(Boolean).at(-1);
|
|
||||||
return name && name.includes(".") ? name : "book.epub";
|
|
||||||
} catch {
|
} catch {
|
||||||
return "book.epub";
|
return withoutFragment.replace(/^\.\//, "").replace(/^\/+/, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createEpubApiLoader(manifest: ReadingManifestDto, fetcher: typeof fetch = fetch): EpubApiLoader {
|
||||||
|
const resources = new Map(manifest.resources.map((resource) => [normalizedResourcePath(resource.href), resource]));
|
||||||
|
const responses = new Map<string, Promise<Blob>>();
|
||||||
|
const loadBlob = async (path: string) => {
|
||||||
|
const resource = resources.get(normalizedResourcePath(path));
|
||||||
|
if (!resource) return null;
|
||||||
|
let pending = responses.get(resource.resourceId);
|
||||||
|
if (!pending) {
|
||||||
|
pending = fetcher(resource.url, { credentials: "include", cache: "default" }).then(async (response) => {
|
||||||
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||||||
|
return response.blob();
|
||||||
|
});
|
||||||
|
responses.set(resource.resourceId, pending);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await pending;
|
||||||
|
} catch (error) {
|
||||||
|
responses.delete(resource.resourceId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
loadBlob,
|
||||||
|
loadText: async (path) => (await loadBlob(path))?.text() ?? null,
|
||||||
|
getSize: (path) => resources.get(normalizedResourcePath(path))?.size ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function locationPercent(location: FoliateLocation): number {
|
function locationPercent(location: FoliateLocation): number {
|
||||||
if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
|
if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
|
||||||
if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
|
if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
|
||||||
@ -39,24 +63,18 @@ function locationPercent(location: FoliateLocation): number {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EpubReader({
|
export function EpubReader({ manifest, locator, backHref, mode, onLocatorChange, onControlsChange }: {
|
||||||
url,
|
manifest: ReadingManifestDto;
|
||||||
locator,
|
|
||||||
backHref,
|
|
||||||
mode,
|
|
||||||
onLocatorChange,
|
|
||||||
onControlsChange
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
locator?: string;
|
locator?: string;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
mode: ReaderMode;
|
mode: ReaderMode;
|
||||||
onLocatorChange: (locator: string, percent: number) => void;
|
onLocatorChange: (locator: string, percent: number, readingLocator: string) => void;
|
||||||
onControlsChange: (controls: ReaderControls) => void;
|
onControlsChange: (controls: ReaderControls) => void;
|
||||||
}) {
|
}) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<FoliateView | null>(null);
|
const viewRef = useRef<FoliateView | null>(null);
|
||||||
const locatorRef = useRef(locator);
|
const locatorRef = useRef(locator);
|
||||||
|
const sectionIndexRef = useRef(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
@ -65,43 +83,39 @@ export function EpubReader({
|
|||||||
onControlsChange({
|
onControlsChange({
|
||||||
canPrevious: !loading && !error,
|
canPrevious: !loading && !error,
|
||||||
canNext: !loading && !error,
|
canNext: !loading && !error,
|
||||||
positionLabel: loading ? "Ouverture EPUB" : "Lecture integree",
|
positionLabel: loading ? "Ouverture de la page…" : "Lecture intégrée",
|
||||||
onPrevious: () => void viewRef.current?.goLeft(),
|
onPrevious: () => void viewRef.current?.goLeft(),
|
||||||
onNext: () => void viewRef.current?.goRight()
|
onNext: () => void viewRef.current?.goRight()
|
||||||
});
|
});
|
||||||
}, [error, loading, onControlsChange]);
|
}, [error, loading, onControlsChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { locatorRef.current = locator; }, [locator]);
|
||||||
locatorRef.current = locator;
|
|
||||||
}, [locator]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let view: FoliateView | null = null;
|
let view: FoliateView | null = null;
|
||||||
|
|
||||||
async function mount() {
|
async function mount() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
await import("foliate-js/view.js");
|
const [{ EPUB }] = await Promise.all([import("foliate-js/epub.js"), import("foliate-js/view.js")]);
|
||||||
if (cancelled || !hostRef.current) return;
|
if (cancelled || !hostRef.current) return;
|
||||||
|
const book = await new EPUB(createEpubApiLoader(manifest)).init();
|
||||||
const response = await fetch(url, { credentials: "include" });
|
|
||||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
||||||
const blob = await response.blob();
|
|
||||||
if (cancelled || !hostRef.current) return;
|
if (cancelled || !hostRef.current) return;
|
||||||
|
|
||||||
view = document.createElement("foliate-view") as FoliateView;
|
view = document.createElement("foliate-view") as FoliateView;
|
||||||
view.classList.add("epub-view");
|
view.classList.add("epub-view");
|
||||||
|
view.addEventListener("load", (event) => {
|
||||||
|
const index = (event as CustomEvent<{ index?: number }>).detail?.index;
|
||||||
|
if (typeof index === "number") sectionIndexRef.current = index;
|
||||||
|
});
|
||||||
view.addEventListener("relocate", (event) => {
|
view.addEventListener("relocate", (event) => {
|
||||||
const location = (event as CustomEvent<FoliateLocation>).detail;
|
const location = (event as CustomEvent<FoliateLocation>).detail;
|
||||||
if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location));
|
const spine = manifest.spine[sectionIndexRef.current];
|
||||||
|
if (location?.cfi && spine) onLocatorChange(location.cfi, locationPercent(location), spine.resourceId);
|
||||||
});
|
});
|
||||||
hostRef.current.replaceChildren(view);
|
hostRef.current.replaceChildren(view);
|
||||||
viewRef.current = view;
|
viewRef.current = view;
|
||||||
|
await view.open(book);
|
||||||
const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
|
|
||||||
await view.open(file);
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (locatorRef.current) await view.goTo(locatorRef.current);
|
if (locatorRef.current) await view.goTo(locatorRef.current);
|
||||||
else await view.next();
|
else await view.next();
|
||||||
@ -113,7 +127,6 @@ export function EpubReader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void mount();
|
void mount();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@ -121,7 +134,7 @@ export function EpubReader({
|
|||||||
view?.remove();
|
view?.remove();
|
||||||
if (viewRef.current === view) viewRef.current = null;
|
if (viewRef.current === view) viewRef.current = null;
|
||||||
};
|
};
|
||||||
}, [attempt, onLocatorChange, url]);
|
}, [attempt, manifest, onLocatorChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
|
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
|
||||||
@ -129,28 +142,10 @@ export function EpubReader({
|
|||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <div className="epub-reader"><ReaderError title="Lecture EPUB indisponible" detail="ReadaBook n'a pas pu ouvrir cette page dans le lecteur web." technicalDetail={error} backHref={backHref} onRetry={() => setAttempt((value) => value + 1)} /></div>;
|
||||||
<div className="epub-reader">
|
|
||||||
<ReaderError
|
|
||||||
title="Lecture EPUB indisponible"
|
|
||||||
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
|
|
||||||
technicalDetail={error}
|
|
||||||
downloadUrl={url}
|
|
||||||
backHref={backHref}
|
|
||||||
onRetry={() => setAttempt((value) => value + 1)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return <div className={`epub-reader epub-reader-${mode}`}>
|
||||||
return (
|
{loading && <div className="reader-fallback"><span>Ouverture de la page…</span></div>}
|
||||||
<div className={`epub-reader epub-reader-${mode}`}>
|
|
||||||
{loading && (
|
|
||||||
<div className="reader-fallback">
|
|
||||||
<span>Ouverture EPUB</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="epub-host" ref={hostRef} />
|
<div className="epub-host" ref={hostRef} />
|
||||||
</div>
|
</div>;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
41
apps/web/src/reader/EpubReaderAdaptive.test.ts
Normal file
41
apps/web/src/reader/EpubReaderAdaptive.test.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ReadingManifestDto } from "../api/types";
|
||||||
|
import { createEpubApiLoader } from "./EpubReader";
|
||||||
|
|
||||||
|
const manifest: ReadingManifestDto = {
|
||||||
|
bookId: 4,
|
||||||
|
format: "epub",
|
||||||
|
revision: "rev-1",
|
||||||
|
containerPath: "META-INF/container.xml",
|
||||||
|
opfPath: "OPS/package.opf",
|
||||||
|
basePath: "OPS/",
|
||||||
|
resources: [
|
||||||
|
{ resourceId: "container", href: "META-INF/container.xml", url: "/books/4/reading-resources/container", mediaType: "application/xml", size: 12, kind: "document", state: "ready" },
|
||||||
|
{ resourceId: "chapter", href: "OPS/text/chapter.xhtml", url: "/books/4/reading-resources/chapter", mediaType: "application/xhtml+xml", size: 18, kind: "chapter", state: "ready" }
|
||||||
|
],
|
||||||
|
spine: [{ index: 0, href: "OPS/text/chapter.xhtml", resourceId: "chapter", url: "/books/4/reading-resources/chapter", mediaType: "application/xhtml+xml", size: 18 }],
|
||||||
|
capabilities: { byteRanges: false, derivedImages: true, lightweightPages: false }
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("EPUB adaptive loader", () => {
|
||||||
|
it("loads manifest resources with credentials and reuses a fetched entry", async () => {
|
||||||
|
const fetcher = vi.fn().mockResolvedValue(new Response("<html>chapter</html>", { status: 200 }));
|
||||||
|
const loader = createEpubApiLoader(manifest, fetcher);
|
||||||
|
|
||||||
|
await expect(loader.loadText("OPS/text/chapter.xhtml#part")).resolves.toBe("<html>chapter</html>");
|
||||||
|
await loader.loadBlob("OPS/text/chapter.xhtml");
|
||||||
|
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetcher).toHaveBeenCalledWith("/books/4/reading-resources/chapter", { credentials: "include", cache: "default" });
|
||||||
|
expect(loader.getSize("OPS/text/chapter.xhtml")).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not contain the complete-book fetch path", () => {
|
||||||
|
const source = readFileSync(new URL("./EpubReader.tsx", import.meta.url), "utf8");
|
||||||
|
expect(source).not.toContain("bookFileUrl");
|
||||||
|
expect(source).not.toContain("fetch(url");
|
||||||
|
expect(source).not.toContain("new File(");
|
||||||
|
expect(source).toContain("new EPUB(createEpubApiLoader(manifest)).init()");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -285,7 +285,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
onControlsChange({
|
onControlsChange({
|
||||||
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
|
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
|
||||||
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
|
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
|
||||||
positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages),
|
positionLabel: loadingDocument ? "Ouverture de la page…" : readerPositionLabel(currentPage, pages),
|
||||||
onPrevious: () => go(currentPage - 1),
|
onPrevious: () => go(currentPage - 1),
|
||||||
onNext: () => go(currentPage + 1)
|
onNext: () => go(currentPage + 1)
|
||||||
});
|
});
|
||||||
@ -584,7 +584,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
|
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
|
||||||
{loadingDocument || !documentProxy ? (
|
{loadingDocument || !documentProxy ? (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>Ouverture PDF</span>
|
<span>Ouverture de la page…</span>
|
||||||
</div>
|
</div>
|
||||||
) : mode === "vertical" ? (
|
) : mode === "vertical" ? (
|
||||||
<div className="reader-vertical-stack">
|
<div className="reader-vertical-stack">
|
||||||
@ -619,7 +619,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
|||||||
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
<div className="pdf-page-frame" data-reader-page={currentPage}>
|
||||||
{(!viewportSize || loadingPage || !pageRendered) && (
|
{(!viewportSize || loadingPage || !pageRendered) && (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>Page {currentPage}</span>
|
<span>Ouverture de la page…</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
|
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export function ReaderError({
|
|||||||
title: string;
|
title: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
technicalDetail?: string;
|
technicalDetail?: string;
|
||||||
downloadUrl: string;
|
downloadUrl?: string;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
}) {
|
}) {
|
||||||
@ -37,10 +37,12 @@ export function ReaderError({
|
|||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Retour à la fiche
|
Retour à la fiche
|
||||||
</button>
|
</button>
|
||||||
|
{downloadUrl && (
|
||||||
<a className="ghost-button" href={downloadUrl} download>
|
<a className="ghost-button" href={downloadUrl} download>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
Télécharger
|
Télécharger
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{technicalDetail && (
|
{technicalDetail && (
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures";
|
import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures";
|
||||||
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
|
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
|
||||||
import type { ReaderMode } from "./readerScroll";
|
import type { ReaderMode } from "./readerScroll";
|
||||||
|
import type { ReadingConnectionMode } from "../api/types";
|
||||||
|
|
||||||
export type ReaderControls = {
|
export type ReaderControls = {
|
||||||
canPrevious: boolean;
|
canPrevious: boolean;
|
||||||
@ -36,6 +37,11 @@ export type ReaderModeControls = {
|
|||||||
onModeChange: (mode: ReaderMode) => void;
|
onModeChange: (mode: ReaderMode) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReaderConnectionControls = {
|
||||||
|
mode: ReadingConnectionMode;
|
||||||
|
onModeChange: (mode: ReadingConnectionMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type ReaderShellProps = {
|
type ReaderShellProps = {
|
||||||
title: string;
|
title: string;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
@ -44,6 +50,8 @@ type ReaderShellProps = {
|
|||||||
controls: ReaderControls;
|
controls: ReaderControls;
|
||||||
zoomControls?: ReaderZoomControls;
|
zoomControls?: ReaderZoomControls;
|
||||||
modeControls?: ReaderModeControls;
|
modeControls?: ReaderModeControls;
|
||||||
|
connectionControls?: ReaderConnectionControls;
|
||||||
|
activity?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -105,7 +113,7 @@ function scheduleReaderZoomAnchorRestore(stage: HTMLElement, anchor: ReaderZoomA
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) {
|
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, connectionControls, activity, children }: ReaderShellProps) {
|
||||||
const readerRef = useRef<HTMLDivElement>(null);
|
const readerRef = useRef<HTMLDivElement>(null);
|
||||||
const headerRef = useRef<HTMLElement>(null);
|
const headerRef = useRef<HTMLElement>(null);
|
||||||
const stageRef = useRef<HTMLElement>(null);
|
const stageRef = useRef<HTMLElement>(null);
|
||||||
@ -368,6 +376,16 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{connectionControls && (
|
||||||
|
<label className="reader-mode-control reader-connection-control">
|
||||||
|
<span>Mode connexion lente</span>
|
||||||
|
<select value={connectionControls.mode} onChange={(event) => connectionControls.onModeChange(event.currentTarget.value as ReadingConnectionMode)}>
|
||||||
|
<option value="auto">Automatique</option>
|
||||||
|
<option value="speed">Toujours privilégier la vitesse</option>
|
||||||
|
<option value="quality">Toujours privilégier la qualité</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<button className="ghost-button reader-nav-button reader-previous-button" onClick={controls.onPrevious} disabled={!controls.canPrevious} aria-label="Page precedente">
|
<button className="ghost-button reader-nav-button reader-previous-button" onClick={controls.onPrevious} disabled={!controls.canPrevious} aria-label="Page precedente">
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
<span>Precedent</span>
|
<span>Precedent</span>
|
||||||
@ -405,6 +423,11 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!error && activity && (
|
||||||
|
<div className="reader-status reader-activity" role="status" aria-live="polite">
|
||||||
|
<span>{activity}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<main className="reader-stage" ref={stageRef}>
|
<main className="reader-stage" ref={stageRef}>
|
||||||
<div className="reader-content">{children}</div>
|
<div className="reader-content">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
41
apps/web/src/reader/ReaderShellAdaptive.test.tsx
Normal file
41
apps/web/src/reader/ReaderShellAdaptive.test.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ReaderShell } from "./ReaderShell";
|
||||||
|
|
||||||
|
describe("ReaderShell adaptive connection controls", () => {
|
||||||
|
it("renders the slow connection mode choices without technical wording or a global percentage", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ReaderShell
|
||||||
|
title="Livre"
|
||||||
|
backHref="/books/1"
|
||||||
|
controls={{ canPrevious: false, canNext: true, positionLabel: "Page 2 / 12", onPrevious: () => undefined, onNext: () => undefined }}
|
||||||
|
connectionControls={{ mode: "auto", onModeChange: () => undefined }}
|
||||||
|
>
|
||||||
|
<p>Page courante</p>
|
||||||
|
</ReaderShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Mode connexion lente");
|
||||||
|
expect(html).toContain("Automatique");
|
||||||
|
expect(html).toContain("Toujours privilégier la vitesse");
|
||||||
|
expect(html).toContain("Toujours privilégier la qualité");
|
||||||
|
expect(html).not.toMatch(/%|pourcentage global|manifest|spine|range|etag|cache|resource|locator/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows retry next to offline or loading errors", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ReaderShell
|
||||||
|
title="Livre"
|
||||||
|
backHref="/books/1"
|
||||||
|
error="Cette page n’a pas pu être chargée. Votre position est conservée."
|
||||||
|
onRetry={() => undefined}
|
||||||
|
controls={{ canPrevious: false, canNext: false, positionLabel: "Page 2 / 12", onPrevious: () => undefined, onNext: () => undefined }}
|
||||||
|
>
|
||||||
|
<p>Page courante</p>
|
||||||
|
</ReaderShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Cette page n’a pas pu être chargée. Votre position est conservée.");
|
||||||
|
expect(html).toContain("Reessayer");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { epubFileName } from "./EpubReader";
|
|
||||||
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
||||||
import {
|
import {
|
||||||
clampReaderPage,
|
clampReaderPage,
|
||||||
@ -23,11 +22,6 @@ import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from
|
|||||||
import { readerErrorMessage } from "./ReaderError";
|
import { readerErrorMessage } from "./ReaderError";
|
||||||
|
|
||||||
describe("reader runtime helpers", () => {
|
describe("reader runtime helpers", () => {
|
||||||
it("extracts an EPUB file name from the file URL", () => {
|
|
||||||
expect(epubFileName("http://readabook.local/books/12/file?token=abc")).toBe("book.epub");
|
|
||||||
expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps PDF.js worker fallback source on the bundled module worker", () => {
|
it("keeps PDF.js worker fallback source on the bundled module worker", () => {
|
||||||
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
|
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
|
||||||
});
|
});
|
||||||
|
|||||||
21
apps/web/src/reader/useAdaptiveReading.test.ts
Normal file
21
apps/web/src/reader/useAdaptiveReading.test.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { adaptiveStatusLabel } from "./useAdaptiveReading";
|
||||||
|
|
||||||
|
describe("adaptive reading status", () => {
|
||||||
|
it("uses the short reader wording without a global percentage", () => {
|
||||||
|
expect(adaptiveStatusLabel("opening", false)).toBe("Ouverture de la page…");
|
||||||
|
expect(adaptiveStatusLabel("next", false)).toBe("Préparation de la page suivante…");
|
||||||
|
expect(adaptiveStatusLabel("neighbors", false)).toBe("Chargement des pages voisines…");
|
||||||
|
expect(adaptiveStatusLabel("slow", false)).toBe("Connexion lente. On charge d’abord cette page.");
|
||||||
|
expect(adaptiveStatusLabel("offline", false)).toBe("Hors connexion. La page déjà chargée reste disponible.");
|
||||||
|
|
||||||
|
for (const status of ["opening", "next", "neighbors", "slow", "offline", "idle"] as const) {
|
||||||
|
const label = adaptiveStatusLabel(status, false) ?? "";
|
||||||
|
expect(label).not.toMatch(/%|pourcentage|global|manifest|spine|range|etag|cache|resource|locator/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces a lightweight resource when the manifest supports it", () => {
|
||||||
|
expect(adaptiveStatusLabel("opening", true)).toBe("Version allégée");
|
||||||
|
});
|
||||||
|
});
|
||||||
134
apps/web/src/reader/useAdaptiveReading.ts
Normal file
134
apps/web/src/reader/useAdaptiveReading.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { ReadingConnectionMode, ReadingManifestDto, ReadingNetworkHint, ReadingWindowItemDto } from "../api/types";
|
||||||
|
|
||||||
|
const CONNECTION_MODE_KEY = "readabook:reading-connection-mode";
|
||||||
|
|
||||||
|
export type AdaptiveReadingStatus = "opening" | "next" | "neighbors" | "slow" | "offline" | "idle";
|
||||||
|
|
||||||
|
function storedMode(): ReadingConnectionMode {
|
||||||
|
if (typeof localStorage === "undefined") return "auto";
|
||||||
|
const value = localStorage.getItem(CONNECTION_MODE_KEY);
|
||||||
|
return value === "speed" || value === "quality" ? value : "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readingNetworkHint(): ReadingNetworkHint {
|
||||||
|
if (typeof navigator === "undefined") return "unknown";
|
||||||
|
if (!navigator.onLine) return "offline";
|
||||||
|
const connection = (navigator as Navigator & { connection?: { effectiveType?: string; saveData?: boolean } }).connection;
|
||||||
|
if (connection?.saveData || connection?.effectiveType === "slow-2g" || connection?.effectiveType === "2g") return "slow";
|
||||||
|
if (connection?.effectiveType === "3g") return "normal";
|
||||||
|
if (connection?.effectiveType === "4g") return "fast";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adaptiveStatusLabel(status: AdaptiveReadingStatus, lightweight: boolean): string | undefined {
|
||||||
|
if (lightweight) return "Version allégée";
|
||||||
|
if (status === "opening") return "Ouverture de la page…";
|
||||||
|
if (status === "next") return "Préparation de la page suivante…";
|
||||||
|
if (status === "neighbors") return "Chargement des pages voisines…";
|
||||||
|
if (status === "slow") return "Connexion lente. On charge d’abord cette page.";
|
||||||
|
if (status === "offline") return "Hors connexion. La page déjà chargée reste disponible.";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdaptiveReading(bookId: number, enabled: boolean) {
|
||||||
|
const [manifest, setManifest] = useState<ReadingManifestDto | null>(null);
|
||||||
|
const [mode, setModeState] = useState<ReadingConnectionMode>(storedMode);
|
||||||
|
const [status, setStatus] = useState<AdaptiveReadingStatus>("opening");
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const generationRef = useRef(0);
|
||||||
|
const lastLocatorRef = useRef<string | null>(null);
|
||||||
|
const currentResourceRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const loadManifest = useCallback(async () => {
|
||||||
|
if (!enabled) return;
|
||||||
|
setError(undefined);
|
||||||
|
setStatus("opening");
|
||||||
|
try {
|
||||||
|
setManifest(await api.readingManifest(bookId));
|
||||||
|
} catch {
|
||||||
|
setError("Impossible de préparer la lecture. Réessayez sans quitter cette page.");
|
||||||
|
setStatus(readingNetworkHint() === "offline" ? "offline" : "idle");
|
||||||
|
}
|
||||||
|
}, [bookId, enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setManifest(null);
|
||||||
|
void loadManifest();
|
||||||
|
}, [loadManifest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setStatus(navigator.onLine ? "idle" : "offline");
|
||||||
|
window.addEventListener("online", update);
|
||||||
|
window.addEventListener("offline", update);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", update);
|
||||||
|
window.removeEventListener("offline", update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setMode = useCallback((next: ReadingConnectionMode) => {
|
||||||
|
setModeState(next);
|
||||||
|
localStorage.setItem(CONNECTION_MODE_KEY, next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prioritize = useCallback(
|
||||||
|
async (locator: string, prefetch = true) => {
|
||||||
|
if (!manifest) return;
|
||||||
|
lastLocatorRef.current = locator;
|
||||||
|
const generation = ++generationRef.current;
|
||||||
|
const networkHint = readingNetworkHint();
|
||||||
|
setError(undefined);
|
||||||
|
setStatus(networkHint === "offline" ? "offline" : networkHint === "slow" || mode === "speed" ? "slow" : "opening");
|
||||||
|
try {
|
||||||
|
const window = await api.readingWindow(bookId, locator, mode, networkHint);
|
||||||
|
currentResourceRef.current = window.current?.resourceId ?? null;
|
||||||
|
if (generation !== generationRef.current || networkHint === "offline") return;
|
||||||
|
// pdf.js owns its byte-range requests; fetching the resource here would download the whole PDF.
|
||||||
|
if (manifest.format === "pdf" || !prefetch) {
|
||||||
|
setStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fetchItem = async (item: ReadingWindowItemDto | null) => {
|
||||||
|
if (!item) return;
|
||||||
|
const response = await fetch(api.readingResourceUrl(bookId, item.resourceId), { credentials: "include", cache: "default" });
|
||||||
|
if (!response.ok) throw new Error(String(response.status));
|
||||||
|
await response.arrayBuffer();
|
||||||
|
};
|
||||||
|
await fetchItem(window.current);
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
setStatus("next");
|
||||||
|
await Promise.allSettled([fetchItem(window.previous), fetchItem(window.next)]);
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
if (window.neighbors.length) {
|
||||||
|
setStatus("neighbors");
|
||||||
|
await Promise.allSettled(window.neighbors.map(fetchItem));
|
||||||
|
}
|
||||||
|
if (generation === generationRef.current) setStatus("idle");
|
||||||
|
} catch {
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
|
setError("Cette page n’a pas pu être chargée. Votre position est conservée.");
|
||||||
|
setStatus(readingNetworkHint() === "offline" ? "offline" : "idle");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bookId, manifest, mode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const retry = useCallback(async () => {
|
||||||
|
const resourceId = currentResourceRef.current;
|
||||||
|
const locator = lastLocatorRef.current;
|
||||||
|
if (!resourceId || !locator) {
|
||||||
|
await loadManifest();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.retryReadingResource(bookId, resourceId);
|
||||||
|
} catch {
|
||||||
|
// Retrying the GET still gives an already cached response a chance while offline.
|
||||||
|
}
|
||||||
|
await prioritize(locator);
|
||||||
|
}, [bookId, loadManifest, prioritize]);
|
||||||
|
|
||||||
|
return { manifest, mode, setMode, status, statusLabel: adaptiveStatusLabel(status, Boolean(manifest?.capabilities.lightweightPages)), error, retry, prioritize };
|
||||||
|
}
|
||||||
@ -293,6 +293,11 @@ h2 {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.book-card-actions .read-status-action {
|
||||||
|
flex-basis: 180px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.format-pill {
|
.format-pill {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 4px 7px;
|
padding: 4px 7px;
|
||||||
@ -339,6 +344,22 @@ h2 {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.read-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border: 1px solid rgba(45, 111, 99, 0.76);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #d8fff5;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 900;
|
||||||
|
background: rgba(45, 111, 99, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.metadata-pill {
|
.metadata-pill {
|
||||||
width: max-content;
|
width: max-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@ -1188,6 +1209,17 @@ main.app-main-reader .reader-page {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-activity {
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 30px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-connection-control select {
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
.reader-stage {
|
.reader-stage {
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -1515,6 +1547,11 @@ main.app-main-reader .reader-page {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-connection-control {
|
||||||
|
order: 8;
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.reader-mode-control select {
|
.reader-mode-control select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -1545,7 +1582,7 @@ main.app-main-reader .reader-page {
|
|||||||
.reader-back-button span,
|
.reader-back-button span,
|
||||||
.reader-nav-button span,
|
.reader-nav-button span,
|
||||||
.reader-fullscreen-toggle span,
|
.reader-fullscreen-toggle span,
|
||||||
.reader-mode-control > span {
|
.reader-mode-control > span:not(:only-child) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
apps/web/src/vite-env.d.ts
vendored
11
apps/web/src/vite-env.d.ts
vendored
@ -1,9 +1,14 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
declare module "foliate-js/epub.js" {
|
declare module "foliate-js/epub.js" {
|
||||||
const module: unknown;
|
export class EPUB {
|
||||||
export default module;
|
constructor(loader: {
|
||||||
export const EPUB: unknown;
|
loadText(path: string): Promise<string | null>;
|
||||||
|
loadBlob(path: string): Promise<Blob | null>;
|
||||||
|
getSize(path: string): number;
|
||||||
|
});
|
||||||
|
init(): Promise<unknown>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "foliate-js/view.js" {
|
declare module "foliate-js/view.js" {
|
||||||
|
|||||||
20
docker-compose.build.yaml
Normal file
20
docker-compose.build.yaml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
OCI_IMAGE_CREATED: ${OCI_IMAGE_CREATED:-local}
|
||||||
|
OCI_IMAGE_REVISION: ${OCI_IMAGE_REVISION:-local}
|
||||||
|
OCI_IMAGE_VERSION: ${READABOOK_TAG:-local}
|
||||||
|
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/api:${READABOOK_TAG:-local}
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/web/Dockerfile
|
||||||
|
args:
|
||||||
|
OCI_IMAGE_CREATED: ${OCI_IMAGE_CREATED:-local}
|
||||||
|
OCI_IMAGE_REVISION: ${OCI_IMAGE_REVISION:-local}
|
||||||
|
OCI_IMAGE_VERSION: ${READABOOK_TAG:-local}
|
||||||
|
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/web:${READABOOK_TAG:-local}
|
||||||
@ -1,8 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
build:
|
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/api:${READABOOK_TAG:-latest}
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
@ -16,7 +14,7 @@ services:
|
|||||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- /home/anthony/Documents/Projects/ReadaBook/Books:/library:ro
|
- ${READABOOK_LIBRARY_HOST_PATH:-./data/library}:/library:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@ -25,9 +23,7 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/web:${READABOOK_TAG:-latest}
|
||||||
context: .
|
|
||||||
dockerfile: apps/web/Dockerfile
|
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { BootstrapAdminSchema, BookQuerySchema } from "./index.js";
|
import { BookSchema, BootstrapAdminSchema, BookQuerySchema, ProgressSchema, UpdateReadStatusSchema } from "./index.js";
|
||||||
|
|
||||||
describe("shared contracts", () => {
|
describe("shared contracts", () => {
|
||||||
it("validates bootstrap admin payloads", () => {
|
it("validates bootstrap admin payloads", () => {
|
||||||
@ -12,4 +12,43 @@ describe("shared contracts", () => {
|
|||||||
expect(BookQuerySchema.parse({}).limit).toBeUndefined();
|
expect(BookQuerySchema.parse({}).limit).toBeUndefined();
|
||||||
expect(BookQuerySchema.parse({ limit: "50" }).limit).toBe(50);
|
expect(BookQuerySchema.parse({ limit: "50" }).limit).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes read state on book and progress contracts", () => {
|
||||||
|
expect(
|
||||||
|
BookSchema.parse({
|
||||||
|
id: 1,
|
||||||
|
libraryId: 1,
|
||||||
|
title: "Daredevil",
|
||||||
|
author: null,
|
||||||
|
description: null,
|
||||||
|
isbn: null,
|
||||||
|
isbn13: null,
|
||||||
|
language: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedDate: null,
|
||||||
|
format: "cbz",
|
||||||
|
filePath: "/library/Daredevil.cbz",
|
||||||
|
coverPath: null,
|
||||||
|
readStatus: "unread",
|
||||||
|
completedAt: null,
|
||||||
|
readStatusSource: null,
|
||||||
|
fileSize: 42,
|
||||||
|
fileMtime: "2026-08-30T00:00:00.000Z",
|
||||||
|
createdAt: "2026-08-30T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-30T00:00:00.000Z"
|
||||||
|
})
|
||||||
|
).toMatchObject({ readStatus: "unread", completedAt: null, readStatusSource: null });
|
||||||
|
expect(
|
||||||
|
ProgressSchema.parse({
|
||||||
|
bookId: 1,
|
||||||
|
locator: "epubcfi(/6/2)",
|
||||||
|
percent: 95,
|
||||||
|
readStatus: "read",
|
||||||
|
completedAt: "2026-08-30T00:00:00.000Z",
|
||||||
|
readStatusSource: "progress",
|
||||||
|
updatedAt: "2026-08-30T00:00:00.000Z"
|
||||||
|
})
|
||||||
|
).toMatchObject({ readStatus: "read", readStatusSource: "progress" });
|
||||||
|
expect(UpdateReadStatusSchema.parse({ status: "unread" })).toEqual({ status: "unread" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -95,6 +95,19 @@ export const SeriesSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type SeriesDto = z.infer<typeof SeriesSchema>;
|
export type SeriesDto = z.infer<typeof SeriesSchema>;
|
||||||
|
|
||||||
|
export const ReadStatusSchema = z.enum(["unread", "reading", "read"]);
|
||||||
|
export type ReadStatus = z.infer<typeof ReadStatusSchema>;
|
||||||
|
|
||||||
|
export const ReadStatusSourceSchema = z.enum(["progress", "manual"]);
|
||||||
|
export type ReadStatusSource = z.infer<typeof ReadStatusSourceSchema>;
|
||||||
|
|
||||||
|
export const BookReadStateSchema = z.object({
|
||||||
|
readStatus: ReadStatusSchema.optional(),
|
||||||
|
completedAt: z.string().nullable().optional(),
|
||||||
|
readStatusSource: ReadStatusSourceSchema.nullable().optional()
|
||||||
|
});
|
||||||
|
export type BookReadStateDto = z.infer<typeof BookReadStateSchema>;
|
||||||
|
|
||||||
export const BookSchema = z.object({
|
export const BookSchema = z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
libraryId: z.number().int().positive(),
|
libraryId: z.number().int().positive(),
|
||||||
@ -117,6 +130,9 @@ export const BookSchema = z.object({
|
|||||||
series: SeriesSchema.nullable().optional(),
|
series: SeriesSchema.nullable().optional(),
|
||||||
scanStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
|
scanStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
|
||||||
enrichmentStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
|
enrichmentStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
|
||||||
|
readStatus: ReadStatusSchema.optional(),
|
||||||
|
completedAt: z.string().nullable().optional(),
|
||||||
|
readStatusSource: ReadStatusSourceSchema.nullable().optional(),
|
||||||
fileSize: z.number().int().nonnegative(),
|
fileSize: z.number().int().nonnegative(),
|
||||||
fileMtime: z.string(),
|
fileMtime: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
@ -137,6 +153,9 @@ export const ProgressSchema = z.object({
|
|||||||
bookId: z.number().int().positive(),
|
bookId: z.number().int().positive(),
|
||||||
locator: z.string().min(1),
|
locator: z.string().min(1),
|
||||||
percent: z.number().min(0).max(100),
|
percent: z.number().min(0).max(100),
|
||||||
|
readStatus: ReadStatusSchema.optional(),
|
||||||
|
completedAt: z.string().nullable().optional(),
|
||||||
|
readStatusSource: ReadStatusSourceSchema.nullable().optional(),
|
||||||
updatedAt: z.string()
|
updatedAt: z.string()
|
||||||
});
|
});
|
||||||
export type ProgressDto = z.infer<typeof ProgressSchema>;
|
export type ProgressDto = z.infer<typeof ProgressSchema>;
|
||||||
@ -147,6 +166,11 @@ export const UpdateProgressSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type UpdateProgressDto = z.infer<typeof UpdateProgressSchema>;
|
export type UpdateProgressDto = z.infer<typeof UpdateProgressSchema>;
|
||||||
|
|
||||||
|
export const UpdateReadStatusSchema = z.object({
|
||||||
|
status: z.enum(["read", "unread"])
|
||||||
|
});
|
||||||
|
export type UpdateReadStatusDto = z.infer<typeof UpdateReadStatusSchema>;
|
||||||
|
|
||||||
export const ReaderPreferencesSchema = z.object({
|
export const ReaderPreferencesSchema = z.object({
|
||||||
mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).default("paged"),
|
mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).default("paged"),
|
||||||
fit: z.enum(["page", "width", "height", "auto"]).nullable().default(null),
|
fit: z.enum(["page", "width", "height", "auto"]).nullable().default(null),
|
||||||
|
|||||||
41
scripts/publish.sh
Executable file
41
scripts/publish.sh
Executable file
@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}"
|
||||||
|
TAG="${READABOOK_TAG:-latest}"
|
||||||
|
REVISION="${OCI_IMAGE_REVISION:-$(git rev-parse HEAD)}"
|
||||||
|
CREATED="${OCI_IMAGE_CREATED:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
|
||||||
|
PLATFORM="${DOCKER_BUILD_PLATFORM:-}"
|
||||||
|
|
||||||
|
build_args=(
|
||||||
|
--build-arg "OCI_IMAGE_CREATED=${CREATED}"
|
||||||
|
--build-arg "OCI_IMAGE_REVISION=${REVISION}"
|
||||||
|
--build-arg "OCI_IMAGE_VERSION=${TAG}"
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_args=()
|
||||||
|
if [[ -n "${PLATFORM}" ]]; then
|
||||||
|
platform_args=(--platform "${PLATFORM}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Publishing ReadaBook images to ${REGISTRY} with tag ${TAG}"
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
"${platform_args[@]}" \
|
||||||
|
"${build_args[@]}" \
|
||||||
|
--tag "${REGISTRY}/api:${TAG}" \
|
||||||
|
--push \
|
||||||
|
--file Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
"${platform_args[@]}" \
|
||||||
|
"${build_args[@]}" \
|
||||||
|
--tag "${REGISTRY}/web:${TAG}" \
|
||||||
|
--push \
|
||||||
|
--file apps/web/Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo "Published:"
|
||||||
|
echo " ${REGISTRY}/api:${TAG}"
|
||||||
|
echo " ${REGISTRY}/web:${TAG}"
|
||||||
Reference in New Issue
Block a user