diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index da1de95..e3e7c93 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,11 +5,12 @@ import { AuthModule } from "./auth/auth.module.js"; import { BooksModule } from "./books/books.module.js"; import { DatabaseModule } from "./database/database.module.js"; import { ProgressModule } from "./progress/progress.module.js"; +import { ReaderModule } from "./reader/reader.module.js"; import { ScannerModule } from "./scanner/scanner.module.js"; import { HealthController } from "./health.controller.js"; @Module({ - imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ScannerModule, AutomationModule], + imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule], controllers: [HealthController] }) export class AppModule {} diff --git a/apps/api/src/database/database.service.ts b/apps/api/src/database/database.service.ts index 62acb45..3cd07a9 100644 --- a/apps/api/src/database/database.service.ts +++ b/apps/api/src/database/database.service.ts @@ -58,6 +58,7 @@ export class DatabaseService implements OnModuleDestroy { isbn TEXT, isbn13 TEXT, identifiers_json TEXT, + local_metadata_json TEXT, language TEXT, publisher TEXT, published_date TEXT, @@ -143,6 +144,7 @@ export class DatabaseService implements OnModuleDestroy { `); this.ensureBooksSupportsComicArchives(); this.ensureBooksMetadataColumns(); + this.ensureReaderPreferencesTable(); this.ensureMetadataSourceConfigColumns(); this.ensureAutomationSettingsColumns(); this.ensureMetadataDefaults(); @@ -174,6 +176,7 @@ export class DatabaseService implements OnModuleDestroy { isbn TEXT, isbn13 TEXT, identifiers_json TEXT, + local_metadata_json TEXT, language TEXT, publisher TEXT, published_date TEXT, @@ -186,11 +189,11 @@ export class DatabaseService implements OnModuleDestroy { updated_at TEXT NOT NULL ); INSERT INTO books ( - id, library_id, title, author, description, isbn, isbn13, identifiers_json, language, publisher, published_date, + id, library_id, title, author, description, isbn, isbn13, identifiers_json, local_metadata_json, language, publisher, published_date, format, file_path, cover_path, file_size, file_mtime, created_at, updated_at ) SELECT - id, library_id, title, author, description, isbn, NULL, NULL, language, publisher, published_date, + id, library_id, title, author, description, isbn, NULL, NULL, NULL, language, publisher, published_date, format, file_path, cover_path, file_size, file_mtime, created_at, updated_at FROM books_legacy_format; DROP TABLE books_legacy_format; @@ -232,7 +235,27 @@ export class DatabaseService implements OnModuleDestroy { if (!names.has("identifiers_json")) { this.sqlite.exec("ALTER TABLE books ADD COLUMN identifiers_json TEXT"); } + if (!names.has("local_metadata_json")) { + this.sqlite.exec("ALTER TABLE books ADD COLUMN local_metadata_json TEXT"); + } this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)"); + this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_local_metadata_idx ON books(local_metadata_json)"); + } + + private ensureReaderPreferencesTable(): void { + this.sqlite.exec(` + CREATE TABLE IF NOT EXISTS reader_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE, + mode TEXT NOT NULL CHECK (mode IN ('paged','scrolled','horizontal','vertical')), + fit TEXT CHECK (fit IN ('page','width','height','auto')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, book_id) + ); + CREATE INDEX IF NOT EXISTS reader_preferences_user_idx ON reader_preferences(user_id); + `); } private ensureMetadataSourceConfigColumns(): void { diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 87800de..8951e18 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -36,6 +36,7 @@ export const books = sqliteTable( isbn: text("isbn"), isbn13: text("isbn13"), identifiersJson: text("identifiers_json"), + localMetadataJson: text("local_metadata_json"), language: text("language"), publisher: text("publisher"), publishedDate: text("published_date"), @@ -50,6 +51,24 @@ export const books = sqliteTable( (table) => ({ filePathIdx: uniqueIndex("books_file_path_unique").on(table.filePath) }) ); +export const readerPreferences = sqliteTable( + "reader_preferences", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + bookId: integer("book_id") + .notNull() + .references(() => books.id, { onDelete: "cascade" }), + mode: text("mode", { enum: ["paged", "scrolled", "horizontal", "vertical"] }).notNull(), + fit: text("fit", { enum: ["page", "width", "height", "auto"] }), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull() + }, + (table) => ({ userBookIdx: uniqueIndex("reader_preferences_user_book_unique").on(table.userId, table.bookId) }) +); + export const progress = sqliteTable( "progress", { diff --git a/apps/api/src/metadata/adapters/bnf.provider.ts b/apps/api/src/metadata/adapters/bnf.provider.ts index 5c26d5e..575d20d 100644 --- a/apps/api/src/metadata/adapters/bnf.provider.ts +++ b/apps/api/src/metadata/adapters/bnf.provider.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { XMLParser } from "fast-xml-parser"; -import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js"; +import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js"; import { toIsbn13 } from "../use-cases/extract-identifiers.js"; const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", removeNSPrefix: true }); @@ -11,30 +11,52 @@ export class BnfProvider implements MetadataProvider { async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise { const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10; - const query = isbn - ? `bib.isbn all "${isbn}"` - : `bib.title all "${lookup.title.replace(/"/g, " ")}"`; + if (!isbn) { + const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, _config); + return matches[0] ?? null; + } + const query = `bib.isbn all "${isbn}"`; + const matches = await this.searchSru(query, 1, lookup.identifiers.isbn13); + return matches[0] ?? null; + } + + async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise { + const title = query.title.replace(/"/g, " "); + const author = query.author?.replace(/"/g, " "); + const sruQuery = [`bib.title all "${title}"`, author ? `bib.author all "${author}"` : null, query.year ? `bib.date all "${query.year}"` : null] + .filter(Boolean) + .join(" and "); + return this.searchSru(sruQuery, 5, query.isbn ? toIsbn13(query.isbn) : null); + } + + private async searchSru(query: string, maximumRecords: number, expectedIsbn13: string | null): Promise { const url = new URL("https://catalogue.bnf.fr/api/SRU"); url.searchParams.set("version", "1.2"); url.searchParams.set("operation", "searchRetrieve"); url.searchParams.set("query", query); - url.searchParams.set("maximumRecords", "1"); + url.searchParams.set("maximumRecords", String(maximumRecords)); const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); - if (!response.ok) return null; + if (!response.ok) return []; const parsed = parser.parse(await response.text()); - const record = parsed?.searchRetrieveResponse?.records?.record?.recordData?.record; - if (!record) return null; - const fields = asArray(record.datafield); - return { - title: subfield(fields, "200", "a") ?? undefined, - author: subfield(fields, "200", "f") ?? ([subfield(fields, "700", "b"), subfield(fields, "700", "a")].filter(Boolean).join(" ") || null), - description: subfield(fields, "330", "a"), - isbn: bestIsbn(fields, lookup.identifiers.isbn13), - language: subfield(fields, "101", "a"), - publisher: subfield(fields, "210", "c") ?? subfield(fields, "214", "c"), - publishedDate: cleanDate(subfield(fields, "210", "d") ?? subfield(fields, "214", "d")) - }; + const records = asArray(parsed?.searchRetrieveResponse?.records?.record) + .map((entry) => (entry.recordData as Record | undefined)?.record) + .filter((record): record is Record => Boolean(record)); + if (!records.length) return []; + return records + .map((record) => { + const fields = asArray(record.datafield); + return { + title: subfield(fields, "200", "a") ?? undefined, + author: subfield(fields, "200", "f") ?? ([subfield(fields, "700", "b"), subfield(fields, "700", "a")].filter(Boolean).join(" ") || null), + description: subfield(fields, "330", "a"), + isbn: bestIsbn(fields, expectedIsbn13), + language: subfield(fields, "101", "a"), + publisher: subfield(fields, "210", "c") ?? subfield(fields, "214", "c"), + publishedDate: cleanDate(subfield(fields, "210", "d") ?? subfield(fields, "214", "d")) + }; + }) + .sort((left, right) => Number(Boolean(right.isbn)) - Number(Boolean(left.isbn))); } } @@ -60,7 +82,11 @@ function bestIsbn(fields: Array>, expectedIsbn13: string .filter((item) => item["@_code"] === "a") .map((item) => String(item["#text"] ?? "").replace(/[^0-9X]/gi, "")) .filter(Boolean); - return values.find((candidate) => toIsbn13(candidate) === expectedIsbn13) ?? values.find((candidate) => toIsbn13(candidate)) ?? null; + return ( + values.find((candidate) => Boolean(expectedIsbn13) && toIsbn13(candidate) === expectedIsbn13) ?? + values.find((candidate) => Boolean(toIsbn13(candidate))) ?? + null + ); } function cleanDate(value: string | null): string | null { diff --git a/apps/api/src/metadata/adapters/google-books.provider.ts b/apps/api/src/metadata/adapters/google-books.provider.ts index 3b9090b..793a1d8 100644 --- a/apps/api/src/metadata/adapters/google-books.provider.ts +++ b/apps/api/src/metadata/adapters/google-books.provider.ts @@ -1,5 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js"; +import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js"; import { toIsbn13 } from "../use-cases/extract-identifiers.js"; @Injectable() @@ -8,9 +8,11 @@ export class GoogleBooksProvider implements MetadataProvider { async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise { const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10; - const query = isbn - ? `isbn:${isbn}` - : `intitle:${lookup.title}${lookup.author ? `+inauthor:${lookup.author}` : ""}`; + if (!isbn) { + const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config); + return matches[0] ?? null; + } + const query = `isbn:${isbn}`; const url = new URL("https://www.googleapis.com/books/v1/volumes"); url.searchParams.set("q", query); url.searchParams.set("maxResults", "1"); @@ -32,6 +34,30 @@ export class GoogleBooksProvider implements MetadataProvider { isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, lookup.identifiers.isbn13) }; } + + async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise { + const url = new URL("https://www.googleapis.com/books/v1/volumes"); + url.searchParams.set("q", `intitle:${query.title}${query.author ? `+inauthor:${query.author}` : ""}`); + url.searchParams.set("maxResults", "5"); + url.searchParams.set("printType", "books"); + if (config.apiKey) url.searchParams.set("key", config.apiKey); + + const response = await fetch(url, { signal: AbortSignal.timeout(4000) }); + if (!response.ok) return []; + const data = (await response.json()) as { items?: Array<{ volumeInfo?: Record }> }; + return (data.items ?? []) + .map((item) => item.volumeInfo) + .filter((info): info is Record => Boolean(info)) + .map((info) => ({ + title: stringValue(info.title) ?? undefined, + author: arrayJoin(info.authors), + description: stringValue(info.description), + language: stringValue(info.language), + publisher: stringValue(info.publisher), + publishedDate: stringValue(info.publishedDate), + isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, query.isbn ? toIsbn13(query.isbn) : null) + })); + } } function stringValue(value: unknown): string | null { @@ -45,7 +71,7 @@ function arrayJoin(value: unknown): string | null { function isbnFromIndustryIdentifiers(value: unknown, expectedIsbn13: string | null): string | null { if (!Array.isArray(value)) return null; const entries = value as Array<{ type?: unknown; identifier?: unknown }>; - const matching = entries.find((entry) => toIsbn13(stringValue(entry.identifier) ?? "") === expectedIsbn13)?.identifier; + const matching = entries.find((entry) => Boolean(expectedIsbn13) && toIsbn13(stringValue(entry.identifier) ?? "") === expectedIsbn13)?.identifier; if (matching) return stringValue(matching); const isbn13 = entries.find((entry) => entry.type === "ISBN_13")?.identifier; const isbn10 = entries.find((entry) => entry.type === "ISBN_10")?.identifier; diff --git a/apps/api/src/metadata/adapters/local.provider.ts b/apps/api/src/metadata/adapters/local.provider.ts index 5223716..64061cf 100644 --- a/apps/api/src/metadata/adapters/local.provider.ts +++ b/apps/api/src/metadata/adapters/local.provider.ts @@ -1,5 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js"; +import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js"; @Injectable() export class LocalMetadataProvider implements MetadataProvider { @@ -13,4 +13,15 @@ export class LocalMetadataProvider implements MetadataProvider { identifiers: lookup.identifiers }; } + + async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise { + return [ + { + title: query.title, + author: query.author, + isbn: query.isbn ?? null, + publishedDate: query.year ?? null + } + ]; + } } diff --git a/apps/api/src/metadata/adapters/open-library.provider.ts b/apps/api/src/metadata/adapters/open-library.provider.ts index 242a05b..892421f 100644 --- a/apps/api/src/metadata/adapters/open-library.provider.ts +++ b/apps/api/src/metadata/adapters/open-library.provider.ts @@ -1,5 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js"; +import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js"; import { toIsbn13 } from "../use-cases/extract-identifiers.js"; @Injectable() @@ -11,26 +11,45 @@ export class OpenLibraryProvider implements MetadataProvider { if (isbn) { return this.lookupIsbn(isbn, lookup.identifiers.isbn13); } - const query = `title:${lookup.title}${lookup.author ? ` author:${lookup.author}` : ""}`; + if (lookup.sourceId) { + return this.lookupEdition(lookup.sourceId, lookup.identifiers.isbn13); + } + const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, _config); + return matches[0] ?? null; + } + + async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise { const url = new URL("https://openlibrary.org/search.json"); - url.searchParams.set("q", query); - url.searchParams.set("limit", "1"); + url.searchParams.set("title", query.title); + if (query.author) url.searchParams.set("author", query.author); + if (query.year) url.searchParams.set("first_publish_year", query.year); + url.searchParams.set("limit", "5"); const response = await fetch(url, { headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" }, signal: AbortSignal.timeout(4000) }); - if (!response.ok) return null; + if (!response.ok) return []; const data = (await response.json()) as { docs?: Array> }; - const doc = data.docs?.[0]; - if (!doc) return null; - return { + return (data.docs ?? []).map((doc) => ({ title: stringValue(doc.title) ?? undefined, + sourceId: firstArrayValue(doc.edition_key) ?? stringValue(doc.cover_edition_key), author: arrayJoin(doc.author_name), language: firstArrayValue(doc.language), publisher: firstArrayValue(doc.publisher), publishedDate: String(doc.first_publish_year ?? "") || null, - isbn: bestIsbn(doc.isbn, lookup.identifiers.isbn13) - }; + isbn: bestIsbn(doc.isbn, query.isbn ? toIsbn13(query.isbn) : null) + })); + } + + private async lookupEdition(sourceId: string, expectedIsbn13: string | null): Promise { + const editionKey = sourceId.replace(/^\/?books\//, ""); + if (!editionKey) return null; + const response = await fetch(`https://openlibrary.org/books/${encodeURIComponent(editionKey)}.json`, { + headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" }, + signal: AbortSignal.timeout(4000) + }); + if (!response.ok) return null; + return this.editionToMatch((await response.json()) as Record, expectedIsbn13); } private async lookupIsbn(isbn: string, expectedIsbn13: string | null): Promise { @@ -39,7 +58,10 @@ export class OpenLibraryProvider implements MetadataProvider { signal: AbortSignal.timeout(4000) }); if (!response.ok) return null; - const edition = (await response.json()) as Record; + return this.editionToMatch((await response.json()) as Record, expectedIsbn13); + } + + private async editionToMatch(edition: Record, expectedIsbn13: string | null): Promise { const author = await this.lookupAuthorName(edition.authors); return { title: stringValue(edition.title) ?? undefined, @@ -81,7 +103,11 @@ function arrayJoin(value: unknown): string | null { function bestIsbn(value: unknown, expectedIsbn13: string | null): string | null { if (!Array.isArray(value)) return null; const values = value.map(String); - return values.find((candidate) => toIsbn13(candidate) === expectedIsbn13) ?? values.find((candidate) => toIsbn13(candidate)) ?? null; + return ( + values.find((candidate) => Boolean(expectedIsbn13) && toIsbn13(candidate) === expectedIsbn13) ?? + values.find((candidate) => Boolean(toIsbn13(candidate))) ?? + null + ); } function asStringArray(value: unknown): string[] { diff --git a/apps/api/src/metadata/extract-identifiers.test.ts b/apps/api/src/metadata/extract-identifiers.test.ts index 56b1ad4..e615996 100644 --- a/apps/api/src/metadata/extract-identifiers.test.ts +++ b/apps/api/src/metadata/extract-identifiers.test.ts @@ -9,6 +9,7 @@ describe("ISBN normalization", () => { expect(normalizeIsbn("0-306-40615-2")).toBe("0306406152"); expect(normalizeIsbn("978-0-306-40615-7")).toBe("9780306406157"); expect(normalizeIsbn("978-0-306-40615-8")).toBeNull(); + expect(normalizeIsbn("5030931067112")).toBeNull(); }); it("converts ISBN-10 to ISBN-13", () => { diff --git a/apps/api/src/metadata/local-metadata-hints.test.ts b/apps/api/src/metadata/local-metadata-hints.test.ts new file mode 100644 index 0000000..19f9e2b --- /dev/null +++ b/apps/api/src/metadata/local-metadata-hints.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { ExtractLocalMetadataHints } from "./use-cases/extract-local-metadata-hints.js"; +import { ScoreMetadataMatch } from "./use-cases/score-metadata-match.js"; + +describe("local metadata hints", () => { + it("extracts title, author and year hints from a book without ISBN", () => { + const hints = new ExtractLocalMetadataHints().fromMetadataAndFile( + { + title: "Harry Potter et le Prince de Sang Mele", + author: null, + description: null, + isbn: null, + language: null, + publisher: null, + publishedDate: null, + coverPath: null + }, + "/library/Harry Potter et le Prince de Sang Mele (J. K. Rowling) 2005.epub" + ); + + expect(hints).toMatchObject({ + title: "Harry Potter et le Prince de Sang Mele", + author: "J. K. Rowling", + year: "2005", + isbn: null + }); + }); +}); + +describe("metadata match scoring", () => { + it("keeps the best remote match for locally extracted title and author", () => { + const best = new ScoreMetadataMatch().best( + { title: "Harry Potter et le Prince de Sang Mele", author: "J. K. Rowling", year: "2005" }, + [ + { title: "Harry Potter et la chambre des secrets", author: "J. K. Rowling", publishedDate: "1998" }, + { title: "Harry Potter et le Prince de sang-mêlé", author: "J.K. Rowling", publishedDate: "2005" } + ] + ); + + expect(best?.match.title).toBe("Harry Potter et le Prince de sang-mêlé"); + expect(best?.score).toBeGreaterThan(0.7); + }); +}); diff --git a/apps/api/src/metadata/metadata-providers.test.ts b/apps/api/src/metadata/metadata-providers.test.ts index 05e51f1..da905c1 100644 --- a/apps/api/src/metadata/metadata-providers.test.ts +++ b/apps/api/src/metadata/metadata-providers.test.ts @@ -8,7 +8,20 @@ const lookup: MetadataLookup = { title: "Harry Potter et la Chambre des Secrets", author: "J. K. Rowling", filePath: "/library/HP/Harry Potter et la Chambre des Secrets (J.K. Rowling).epub", - identifiers: { isbn10: null, isbn13: "9782070612376", candidates: ["9782070612376"] } + identifiers: { isbn10: null, isbn13: "9782070612376", candidates: ["9782070612376"] }, + local: { + title: "Harry Potter et la Chambre des Secrets", + author: "J. K. Rowling", + year: null, + isbn: "9782070612376", + fileTitle: "Harry Potter et la Chambre des Secrets (J.K. Rowling)", + raw: { + title: "Harry Potter et la Chambre des Secrets", + author: "J. K. Rowling", + publishedDate: null, + fileName: "Harry Potter et la Chambre des Secrets (J.K. Rowling)" + } + } }; const config: MetadataProviderConfig = { provider: "openlibrary", enabled: true, priority: 1, apiKey: null }; @@ -55,6 +68,67 @@ describe("metadata providers", () => { expect(result).toBeNull(); }); + it("queries OpenLibrary by local metadata when ISBN is missing", async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + docs: [ + { + title: "Harry Potter et le Prince de sang-mêlé", + author_name: ["J. K. Rowling"], + first_publish_year: 2005, + publisher: ["Gallimard jeunesse"], + cover_edition_key: "OL24333986M", + isbn: ["9782070612383"] + } + ] + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await new OpenLibraryProvider().searchByMetadata( + { title: "Harry Potter et le Prince de Sang Mele", author: "J. K. Rowling", year: "2005" }, + config + ); + const url = new URL(String((fetchMock.mock.calls[0] as unknown[])[0])); + + expect(url.searchParams.get("title")).toBe("Harry Potter et le Prince de Sang Mele"); + expect(url.searchParams.get("author")).toBe("J. K. Rowling"); + expect(result[0]).toMatchObject({ + title: "Harry Potter et le Prince de sang-mêlé", + sourceId: "OL24333986M", + author: "J. K. Rowling", + publishedDate: "2005" + }); + }); + + it("looks up OpenLibrary edition details from a search result source id", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + title: "Harry Potter et le prince de sang-mele", + authors: [{ key: "/authors/OL23919A" }], + languages: [{ key: "/languages/fre" }], + publishers: ["Gallimard jeunesse"], + publish_date: "2005", + isbn_13: ["9782070612383"], + description: { value: "Sixième année à Poudlard." } + }) + ) + .mockResolvedValueOnce(jsonResponse({ name: "J. K. Rowling" })); + vi.stubGlobal("fetch", fetchMock); + + const result = await new OpenLibraryProvider().lookup({ ...lookup, sourceId: "OL24333986M", identifiers: { isbn10: null, isbn13: null, candidates: [] } }, config); + + expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toBe("https://openlibrary.org/books/OL24333986M.json"); + expect(result).toMatchObject({ + title: "Harry Potter et le prince de sang-mele", + author: "J. K. Rowling", + isbn: "9782070612383", + description: "Sixième année à Poudlard." + }); + }); + it("parses BnF SRU UNIMARC records returned for ISBN lookup", async () => { vi.stubGlobal( "fetch", @@ -90,6 +164,42 @@ describe("metadata providers", () => { isbn: "9782070612376" }); }); + + it("prefers BnF title search records with a valid book ISBN over non-book EAN records", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + textResponse(` + + + + + 5030931067112 + Harry Potter et le prince de sang-mêléElectronic arts + + + + + 274419736X + Harry Potter et le prince de sang-mêléJ. K. Rowling + + + + `) + ) + ); + + const result = await new BnfProvider().searchByMetadata( + { title: "Harry Potter et le prince de sang mele", author: null }, + { ...config, provider: "bnf" } + ); + + expect(result[0]).toMatchObject({ + author: "J. K. Rowling", + isbn: "274419736X" + }); + expect(result[1]?.isbn).toBeNull(); + }); }); function jsonResponse(body: unknown, status = 200): Response { diff --git a/apps/api/src/metadata/metadata.service.test.ts b/apps/api/src/metadata/metadata.service.test.ts new file mode 100644 index 0000000..0e1adea --- /dev/null +++ b/apps/api/src/metadata/metadata.service.test.ts @@ -0,0 +1,233 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DatabaseService } from "../database/database.service.js"; +import { BookMetadata } from "../scanner/metadata.js"; +import { MetadataService } from "./metadata.service.js"; +import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "./metadata.types.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; + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("MetadataService", () => { + it.runIf(canLoadBetterSqlite())("backfills description from provider lookup after a metadata search hit yields an ISBN", async () => { + const database = createDatabase(); + const localProvider = providerStub("local"); + const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise>(async () => [ + { + title: "Harry Potter et le Prince de sang-mêlé", + author: "J. K. Rowling", + isbn: "9782070612383", + publishedDate: "2005" + } + ]); + const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise>(async (lookup) => { + if (lookup.identifiers.isbn13 !== "9782070612383") return null; + return { + title: "Harry Potter et le Prince de sang-mêlé", + author: "J. K. Rowling", + isbn: "9782070612383", + publishedDate: "2005", + description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé." + }; + }); + const openLibraryProvider: MetadataProvider = { + id: "openlibrary", + searchByMetadata: openLibrarySearch, + lookup: openLibraryLookup + }; + const service = new MetadataService( + database, + localProvider as never, + openLibraryProvider as never, + providerStub("googlebooks") as never, + providerStub("bnf") as never + ); + const localMetadata: BookMetadata = { + title: "Harry Potter et le Prince de Sang Mele", + author: "J. K. Rowling", + description: null, + isbn: null, + language: null, + publisher: null, + publishedDate: null, + coverPath: null + }; + + const result = await service.enrichMetadata(localMetadata, "/library/HP/Harry Potter et le Prince de Sang Mele.epub", { + remote: true + }); + + expect(openLibrarySearch).toHaveBeenCalledOnce(); + expect(openLibraryLookup).toHaveBeenCalledOnce(); + expect(openLibraryLookup.mock.calls[0]?.[0].identifiers.isbn13).toBe("9782070612383"); + expect(result).toMatchObject({ + title: "Harry Potter et le Prince de sang-mêlé", + isbn: "9782070612383", + isbn13: "9782070612383", + description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé." + }); + + database.onModuleDestroy(); + }); + + it.runIf(canLoadBetterSqlite())("looks up details from a title search hit even when the hit has no ISBN", async () => { + const database = createDatabase(); + const localProvider = providerStub("local"); + const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise>(async () => [ + { + title: "Harry Potter et le prince de sang-mele", + sourceId: "OL24333986M", + publishedDate: "2005" + } + ]); + const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise>(async (lookup) => { + if (lookup.sourceId !== "OL24333986M") return null; + return { + title: "Harry Potter et le prince de sang-mêlé", + author: "J. K. Rowling", + isbn: "9782070612383", + publishedDate: "2005", + description: "Sixième année à Poudlard." + }; + }); + const openLibraryProvider: MetadataProvider = { + id: "openlibrary", + searchByMetadata: openLibrarySearch, + lookup: openLibraryLookup + }; + const service = new MetadataService( + database, + localProvider as never, + openLibraryProvider as never, + providerStub("googlebooks") as never, + providerStub("bnf") as never + ); + const localMetadata: BookMetadata = { + title: "Harry Potter et le prince de sang mele", + author: null, + description: null, + isbn: null, + language: null, + publisher: null, + publishedDate: null, + coverPath: null + }; + + const result = await service.enrichMetadata(localMetadata, "/library/Harry Potter et le prince de sang mele.epub", { + remote: true + }); + + expect(openLibrarySearch).toHaveBeenCalledOnce(); + expect(openLibraryLookup).toHaveBeenCalledOnce(); + expect(openLibraryLookup.mock.calls[0]?.[0].sourceId).toBe("OL24333986M"); + expect(result).toMatchObject({ + title: "Harry Potter et le prince de sang-mêlé", + author: "J. K. Rowling", + isbn: "9782070612383", + isbn13: "9782070612383", + description: "Sixième année à Poudlard." + }); + + database.onModuleDestroy(); + }); + + it.runIf(canLoadBetterSqlite())("replaces an ambiguous title-only identification when a later provider supplies a described record", async () => { + const database = createDatabase(); + database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'bnf'").run(); + const openLibraryProvider: MetadataProvider = { + id: "openlibrary", + lookup: async () => null, + searchByMetadata: async () => [ + { + title: "Daredevil", + author: "Rosemary Carter", + isbn: "9780373105601", + publisher: "Harlequin Books", + publishedDate: "1982" + } + ] + }; + const bnfProvider: MetadataProvider = { + id: "bnf", + lookup: async () => null, + searchByMetadata: async () => [ + { + title: "Daredevil", + author: "scénario, Roy Thomas, Gary Friedrich", + isbn: "9782809476255", + description: "Daredevil affronte l'Homme aux échasses.", + language: "fre", + publisher: "Panini comics", + publishedDate: "2019" + } + ] + }; + const service = new MetadataService( + database, + providerStub("local") as never, + openLibraryProvider as never, + providerStub("googlebooks") as never, + bnfProvider as never + ); + const localMetadata: BookMetadata = { + title: "Daredevil", + author: null, + description: null, + isbn: null, + language: null, + publisher: null, + publishedDate: null, + coverPath: null + }; + + const result = await service.enrichMetadata(localMetadata, "/library/Daredevil.cbz", { remote: true }); + + expect(result).toMatchObject({ + title: "Daredevil", + author: "scénario, Roy Thomas, Gary Friedrich", + isbn: "9782809476255", + isbn13: "9782809476255", + description: "Daredevil affronte l'Homme aux échasses." + }); + + database.onModuleDestroy(); + }); +}); + +function createDatabase(): DatabaseService { + const dir = mkdtempSync(join(tmpdir(), "readabook-metadata-service-")); + tempDirs.push(dir); + process.env.DATABASE_PATH = join(dir, "readabook.sqlite"); + process.env.STORAGE_DIR = join(dir, "storage"); + return new DatabaseService(); +} + +function providerStub(id: MetadataProvider["id"]): MetadataProvider { + return { + id, + lookup: async () => null, + searchByMetadata: async () => [] + }; +} + +function canLoadBetterSqlite(): boolean { + try { + const database = createDatabase(); + database.onModuleDestroy(); + return true; + } catch { + return false; + } +} diff --git a/apps/api/src/metadata/metadata.service.ts b/apps/api/src/metadata/metadata.service.ts index b27001d..1553ba0 100644 --- a/apps/api/src/metadata/metadata.service.ts +++ b/apps/api/src/metadata/metadata.service.ts @@ -11,13 +11,17 @@ import { BnfProvider } from "./adapters/bnf.provider.js"; import { GoogleBooksProvider } from "./adapters/google-books.provider.js"; import { LocalMetadataProvider } from "./adapters/local.provider.js"; import { OpenLibraryProvider } from "./adapters/open-library.provider.js"; -import { MetadataMatch, MetadataProvider, MetadataProviderConfig } from "./metadata.types.js"; +import { BookIdentifiers, LocalMetadataHints, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "./metadata.types.js"; import { ExtractIdentifiers, toIsbn13 } from "./use-cases/extract-identifiers.js"; +import { ExtractLocalMetadataHints } from "./use-cases/extract-local-metadata-hints.js"; import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js"; +import { ScoreMetadataMatch } from "./use-cases/score-metadata-match.js"; @Injectable() export class MetadataService { private readonly extractIdentifiers = new ExtractIdentifiers(); + private readonly extractLocalMetadataHints = new ExtractLocalMetadataHints(); + private readonly scoreMetadataMatch = new ScoreMetadataMatch(); private readonly resolveProviderChain: ResolveProviderChain; constructor( @@ -67,8 +71,13 @@ export class MetadataService { return this.getSourcesConfig(); } - async enrichMetadata(localMetadata: BookMetadata, filePath: string, options: { remote: boolean }): Promise { + async enrichMetadata( + localMetadata: BookMetadata, + filePath: string, + options: { remote: boolean } + ): Promise { const identifiers = this.extractIdentifiers.fromMetadataAndFile(localMetadata, filePath); + const local = this.extractLocalMetadataHints.fromMetadataAndFile(localMetadata, filePath); const configs = this.getProviderConfigs(); const chain = options.remote ? this.resolveProviderChain.resolve(configs) @@ -77,16 +86,42 @@ export class MetadataService { for (const { provider, config } of chain) { try { - const match = await provider.lookup( - { - title: merged.title, - author: merged.author, - filePath, - identifiers - }, - config - ); - if (match) merged = mergeMetadata(merged, match); + const hasIsbn = Boolean(identifiers.isbn13 ?? identifiers.isbn10); + const match = + provider.id === "local" || hasIsbn + ? await provider.lookup( + { + title: merged.title, + author: merged.author, + filePath, + sourceId: null, + identifiers, + local + }, + config + ) + : null; + if (match) { + merged = mergeMetadata(merged, match); + continue; + } + if (!options.remote || provider.id === "local") continue; + const query = { + title: local.title, + author: local.author, + year: local.year, + isbn: identifiers.isbn13 ?? identifiers.isbn10 ?? local.isbn + }; + const best = this.scoreMetadataMatch.best(query, await provider.searchByMetadata(query, config)); + if (best) { + if (!isActionableSearchMatch(best.match)) continue; + merged = shouldReplaceAmbiguousIdentification(localMetadata, merged, best.match) + ? mergeMetadata({ ...localMetadata, author: null, isbn: null, description: null, language: null, publisher: null, publishedDate: null }, best.match) + : mergeMetadata(merged, best.match); + + const detailedMatch = await this.lookupSearchMatchDetails(provider, config, filePath, identifiers, local, merged, best.match); + if (detailedMatch) merged = mergeMetadata(merged, detailedMatch); + } } catch { // Provider failures must not block local ingestion. } @@ -97,7 +132,8 @@ export class MetadataService { ...merged, isbn: merged.isbn ?? isbn13 ?? identifiers.isbn10, isbn13, - identifiersJson: JSON.stringify(identifiers) + identifiersJson: JSON.stringify(identifiers), + localMetadataJson: JSON.stringify(local) }; } @@ -124,6 +160,7 @@ export class MetadataService { isbn: enriched.isbn, isbn13: enriched.isbn13, identifiersJson: enriched.identifiersJson, + localMetadataJson: enriched.localMetadataJson, language: enriched.language, publisher: enriched.publisher, publishedDate: enriched.publishedDate, @@ -151,6 +188,48 @@ export class MetadataService { private getAutomationRow(): typeof automationSettings.$inferSelect { return this.database.db.select().from(automationSettings).where(eq(automationSettings.id, 1)).get()!; } + + private async lookupSearchMatchDetails( + provider: MetadataProvider, + config: MetadataProviderConfig, + filePath: string, + identifiers: BookIdentifiers, + local: LocalMetadataHints, + merged: BookMetadata, + match: MetadataMatch + ): Promise { + const derivedIdentifiers = { + ...identifiers, + isbn13: identifiers.isbn13 ?? (match.isbn ? toIsbn13(match.isbn) : null), + isbn10: identifiers.isbn10 ?? match.isbn ?? null, + candidates: [...new Set([...identifiers.candidates, ...(match.isbn ? [match.isbn] : [])])] + }; + const hasNewIdentifier = derivedIdentifiers.isbn13 !== identifiers.isbn13 || derivedIdentifiers.isbn10 !== identifiers.isbn10; + const hasLookupTarget = hasNewIdentifier || Boolean(match.sourceId); + const needsDetails = !merged.description && hasLookupTarget; + if (!hasLookupTarget && !needsDetails) return null; + + return provider.lookup( + { + title: merged.title, + author: merged.author, + filePath, + sourceId: match.sourceId, + identifiers: derivedIdentifiers, + local + }, + config + ); + } +} + +function isActionableSearchMatch(match: MetadataMatch): boolean { + return Boolean(match.isbn ?? match.sourceId ?? match.description); +} + +function shouldReplaceAmbiguousIdentification(local: BookMetadata, current: BookMetadata, next: MetadataMatch): boolean { + if (local.author || local.isbn || !current.isbn || !next.isbn || current.isbn === next.isbn) return false; + return Boolean(next.description); } function mergeMetadata(current: BookMetadata, next: MetadataMatch): BookMetadata { diff --git a/apps/api/src/metadata/metadata.types.ts b/apps/api/src/metadata/metadata.types.ts index 0e1521c..c23b836 100644 --- a/apps/api/src/metadata/metadata.types.ts +++ b/apps/api/src/metadata/metadata.types.ts @@ -12,10 +12,34 @@ export type MetadataLookup = { title: string; author: string | null; filePath: string; + sourceId?: string | null; identifiers: BookIdentifiers; + local: LocalMetadataHints; +}; + +export type LocalMetadataHints = { + title: string; + author: string | null; + year: string | null; + isbn: string | null; + fileTitle: string; + raw: { + title: string; + author: string | null; + publishedDate: string | null; + fileName: string; + }; +}; + +export type MetadataSearchQuery = { + title: string; + author: string | null; + year?: string | null; + isbn?: string | null; }; export type MetadataMatch = Partial & { + sourceId?: string | null; identifiers?: Partial; }; @@ -29,4 +53,5 @@ export type MetadataProviderConfig = { export interface MetadataProvider { readonly id: MetadataProviderId; lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise; + searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise; } diff --git a/apps/api/src/metadata/resolve-provider-chain.test.ts b/apps/api/src/metadata/resolve-provider-chain.test.ts index b26176b..8e02209 100644 --- a/apps/api/src/metadata/resolve-provider-chain.test.ts +++ b/apps/api/src/metadata/resolve-provider-chain.test.ts @@ -4,7 +4,8 @@ import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js"; const provider = (id: MetadataProvider["id"]): MetadataProvider => ({ id, - lookup: async () => null + lookup: async () => null, + searchByMetadata: async () => [] }); describe("ResolveProviderChain", () => { diff --git a/apps/api/src/metadata/use-cases/extract-identifiers.ts b/apps/api/src/metadata/use-cases/extract-identifiers.ts index a1cec41..2f046af 100644 --- a/apps/api/src/metadata/use-cases/extract-identifiers.ts +++ b/apps/api/src/metadata/use-cases/extract-identifiers.ts @@ -39,7 +39,7 @@ export class ExtractIdentifiers { export function normalizeIsbn(value: string): string | null { const compact = value.replace(/[^0-9X]/gi, "").toUpperCase(); if (compact.length === 10 && isValidIsbn10(compact)) return compact; - if (compact.length === 13 && isValidIsbn13(compact)) return compact; + if (compact.length === 13 && /^97[89]/.test(compact) && isValidIsbn13(compact)) return compact; return null; } diff --git a/apps/api/src/metadata/use-cases/extract-local-metadata-hints.ts b/apps/api/src/metadata/use-cases/extract-local-metadata-hints.ts new file mode 100644 index 0000000..4bf80fd --- /dev/null +++ b/apps/api/src/metadata/use-cases/extract-local-metadata-hints.ts @@ -0,0 +1,66 @@ +import { basename, extname } from "node:path"; +import { BookMetadata } from "../../scanner/metadata.js"; +import { LocalMetadataHints } from "../metadata.types.js"; + +export class ExtractLocalMetadataHints { + fromMetadataAndFile(metadata: BookMetadata, filePath: string): LocalMetadataHints { + const fileName = basename(filePath, extname(filePath)); + const parsed = parseFileName(fileName); + const title = cleanTitle(metadata.title) || parsed.title || fileName; + const author = cleanValue(metadata.author) ?? parsed.author; + const year = yearFrom(metadata.publishedDate) ?? parsed.year; + + return { + title, + author, + year, + isbn: cleanValue(metadata.isbn), + fileTitle: parsed.title ?? fileName, + raw: { + title: metadata.title, + author: metadata.author, + publishedDate: metadata.publishedDate, + fileName + } + }; + } +} + +function parseFileName(fileName: string): { title: string | null; author: string | null; year: string | null } { + let value = fileName.replace(/[_]+/g, " ").replace(/\s+/g, " ").trim(); + const year = yearFrom(value); + if (year) value = value.replace(new RegExp(`\\b${year}\\b`), " "); + + const parenthetical = [...value.matchAll(/\(([^()]{2,120})\)/g)].map((match) => match[1].trim()); + const authorFromParentheses = parenthetical.find((item) => looksLikeAuthor(item)) ?? null; + value = value.replace(/\([^()]*\)/g, " "); + + const split = value.match(/^(.+?)\s+-\s+(.+)$/); + const title = cleanTitle(split?.[1] ?? value); + const author = cleanValue(split?.[2]) ?? authorFromParentheses; + return { title, author, year }; +} + +function cleanTitle(value: string | null): string | null { + if (!value) return null; + const cleaned = value + .replace(/\[[^\]]*\]/g, " ") + .replace(/\b(epub|pdf|retail|ebook|scan)\b/gi, " ") + .replace(/\s+/g, " ") + .trim(); + return cleaned || null; +} + +function cleanValue(value: string | null | undefined): string | null { + if (!value) return null; + const cleaned = value.replace(/\s+/g, " ").trim(); + return cleaned || null; +} + +function yearFrom(value: string | null): string | null { + return value?.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null; +} + +function looksLikeAuthor(value: string): boolean { + return /[A-Za-zÀ-ÖØ-öø-ÿ]/.test(value) && (value.includes(".") || value.includes(" ") || /^[A-Z][a-z]+$/.test(value)); +} diff --git a/apps/api/src/metadata/use-cases/score-metadata-match.ts b/apps/api/src/metadata/use-cases/score-metadata-match.ts new file mode 100644 index 0000000..a81de00 --- /dev/null +++ b/apps/api/src/metadata/use-cases/score-metadata-match.ts @@ -0,0 +1,58 @@ +import { MetadataMatch, MetadataSearchQuery } from "../metadata.types.js"; + +export type ScoredMetadataMatch = { + match: MetadataMatch; + score: number; +}; + +export class ScoreMetadataMatch { + score(query: MetadataSearchQuery, match: MetadataMatch): number { + let score = 0; + const titleScore = similarity(normalize(query.title), normalize(match.title ?? "")); + score += titleScore * 0.7; + + if (query.author && match.author) { + score += similarity(normalize(query.author), normalize(match.author)) * 0.2; + } else if (!query.author) { + score += 0.08; + } + + const queryYear = query.year ?? null; + const matchYear = match.publishedDate?.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null; + if (queryYear && matchYear) score += queryYear === matchYear ? 0.1 : -0.1; + + if (query.isbn && match.isbn && query.isbn.replace(/\D/g, "") === match.isbn.replace(/\D/g, "")) { + score += 0.25; + } + return Math.max(0, Math.min(1, score)); + } + + best(query: MetadataSearchQuery, matches: MetadataMatch[], minimumScore = 0.55): ScoredMetadataMatch | null { + const scored = matches + .map((match) => ({ match, score: this.score(query, match) })) + .sort((left, right) => right.score - left.score); + const best = scored[0]; + return best && best.score >= minimumScore ? best : null; + } +} + +function normalize(value: string): string { + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .replace(/\b(le|la|les|the|a|an|de|du|des|et|and)\b/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function similarity(left: string, right: string): number { + if (!left || !right) return 0; + if (left === right) return 1; + const leftTokens = new Set(left.split(" ")); + const rightTokens = new Set(right.split(" ")); + const intersection = [...leftTokens].filter((token) => rightTokens.has(token)).length; + const union = new Set([...leftTokens, ...rightTokens]).size; + return union ? intersection / union : 0; +} diff --git a/apps/api/src/reader/reader-preferences.controller.ts b/apps/api/src/reader/reader-preferences.controller.ts new file mode 100644 index 0000000..012baeb --- /dev/null +++ b/apps/api/src/reader/reader-preferences.controller.ts @@ -0,0 +1,26 @@ +import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common"; +import { UpdateReaderPreferencesDto, UpdateReaderPreferencesSchema } from "@readabook/shared"; +import { AuthGuard } from "../auth/auth.guard.js"; +import { CurrentUser, CurrentUserParam } from "../auth/current-user.js"; +import { ZodValidationPipe } from "../common/zod-validation.pipe.js"; +import { ReaderPreferencesService } from "./reader-preferences.service.js"; + +@Controller("reader/preferences") +@UseGuards(AuthGuard) +export class ReaderPreferencesController { + constructor(private readonly preferences: ReaderPreferencesService) {} + + @Get(":bookId") + get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) { + return this.preferences.find(user.id, Number(bookId)); + } + + @Put(":bookId") + update( + @CurrentUserParam() user: CurrentUser, + @Param("bookId") bookId: string, + @Body(new ZodValidationPipe(UpdateReaderPreferencesSchema)) body: UpdateReaderPreferencesDto + ) { + return this.preferences.upsert(user.id, Number(bookId), body); + } +} diff --git a/apps/api/src/reader/reader-preferences.service.ts b/apps/api/src/reader/reader-preferences.service.ts new file mode 100644 index 0000000..2c3d716 --- /dev/null +++ b/apps/api/src/reader/reader-preferences.service.ts @@ -0,0 +1,47 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { eq, sql } from "drizzle-orm"; +import { UpdateReaderPreferencesDto } from "@readabook/shared"; +import { DatabaseService } from "../database/database.service.js"; +import { books, readerPreferences } from "../database/schema.js"; + +@Injectable() +export class ReaderPreferencesService { + constructor(private readonly database: DatabaseService) {} + + find(userId: number, bookId: number) { + const row = this.database.db + .select({ + mode: readerPreferences.mode, + fit: readerPreferences.fit, + updatedAt: readerPreferences.updatedAt + }) + .from(readerPreferences) + .where(sql`${readerPreferences.userId} = ${userId} AND ${readerPreferences.bookId} = ${bookId}`) + .get(); + return row ?? { mode: "paged", fit: null }; + } + + upsert(userId: number, bookId: number, input: UpdateReaderPreferencesDto) { + 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"); + + const current = this.find(userId, bookId); + const now = this.database.now(); + const mode = input.mode ?? current.mode; + const fit = input.fit === undefined ? current.fit : input.fit; + + this.database.sqlite + .prepare( + ` + INSERT INTO reader_preferences(user_id, book_id, mode, fit, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, book_id) DO UPDATE SET + mode = excluded.mode, + fit = excluded.fit, + updated_at = excluded.updated_at + ` + ) + .run(userId, bookId, mode, fit, now, now); + return this.find(userId, bookId); + } +} diff --git a/apps/api/src/reader/reader.module.ts b/apps/api/src/reader/reader.module.ts new file mode 100644 index 0000000..b294f8e --- /dev/null +++ b/apps/api/src/reader/reader.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module.js"; +import { DatabaseModule } from "../database/database.module.js"; +import { ReaderPreferencesController } from "./reader-preferences.controller.js"; +import { ReaderPreferencesService } from "./reader-preferences.service.js"; + +@Module({ + imports: [AuthModule, DatabaseModule], + controllers: [ReaderPreferencesController], + providers: [ReaderPreferencesService] +}) +export class ReaderModule {} diff --git a/apps/api/src/scanner/scanner.service.ts b/apps/api/src/scanner/scanner.service.ts index e8aaeaf..b526caa 100644 --- a/apps/api/src/scanner/scanner.service.ts +++ b/apps/api/src/scanner/scanner.service.ts @@ -109,6 +109,7 @@ export class ScannerService { isbn: metadata.isbn, isbn13: metadata.isbn13, identifiersJson: metadata.identifiersJson, + localMetadataJson: metadata.localMetadataJson, language: metadata.language, publisher: metadata.publisher, publishedDate: metadata.publishedDate, @@ -137,6 +138,19 @@ export class ScannerService { isbn: null, isbn13: null, identifiersJson: JSON.stringify({ candidates: [], isbn10: null, isbn13: null }), + localMetadataJson: JSON.stringify({ + title: basename(filePath, extname(filePath)), + author: null, + year: null, + isbn: null, + fileTitle: basename(filePath, extname(filePath)), + raw: { + title: basename(filePath, extname(filePath)), + author: null, + publishedDate: null, + fileName: basename(filePath, extname(filePath)) + } + }), language: null, publisher: null, publishedDate: null, diff --git a/apps/web/src/api/client.test.ts b/apps/web/src/api/client.test.ts index 80ddfdc..09198c9 100644 --- a/apps/web/src/api/client.test.ts +++ b/apps/web/src/api/client.test.ts @@ -51,6 +51,22 @@ describe("api fallback helpers", () => { await expect(api.scanLibrary(42)).rejects.toThrow("offline"); }); + it("keeps reader preferences locally when the backend contract is absent", async () => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear() + }); + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 404, statusText: "Not Found" })); + vi.stubGlobal("fetch", fetchMock); + + await expect(api.readerPreferences(8)).resolves.toEqual({ mode: "horizontal", fit: "page" }); + await expect(api.saveReaderPreferences(8, { mode: "vertical", fit: "width" })).resolves.toEqual({ mode: "vertical", fit: "width" }); + expect(storage.get("readabook:reader-preferences:8")).toBe(JSON.stringify({ mode: "vertical", fit: "width" })); + }); + it("sends metadata source updates to the admin endpoint", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index baeb744..b9ba82a 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -26,9 +26,10 @@ import { mockProgress, mockUser } from "./mockData"; -import type { CbzPagesDto, ContinueItem, Session } from "./types"; +import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; +const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:"; type RequestOptions = RequestInit & { fallback?: unknown; @@ -111,6 +112,30 @@ function queryString(query: Partial): string { return value ? `?${value}` : ""; } +function readerPreferencesKey(bookId: number): string { + return `${READER_PREFERENCES_PREFIX}${bookId}`; +} + +function readLocalReaderPreferences(bookId: number): ReaderPreferencesDto { + if (typeof localStorage === "undefined") return { mode: "horizontal", fit: "page" }; + const raw = localStorage.getItem(readerPreferencesKey(bookId)); + if (!raw) return { mode: "horizontal", fit: "page" }; + try { + const parsed = JSON.parse(raw) as Partial; + return { + mode: parsed.mode === "vertical" ? "vertical" : "horizontal", + fit: parsed.fit === "width" ? "width" : "page" + }; + } catch { + return { mode: "horizontal", fit: "page" }; + } +} + +function writeLocalReaderPreferences(bookId: number, preferences: ReaderPreferencesDto): void { + if (typeof localStorage === "undefined") return; + localStorage.setItem(readerPreferencesKey(bookId), JSON.stringify(preferences)); +} + export const api = { async session(): Promise { try { @@ -176,6 +201,31 @@ export const api = { fallback: { bookId, ...input, updatedAt: new Date().toISOString() } }); }, + async readerPreferences(bookId: number): Promise { + try { + const preferences = await request(`/reader/preferences/${bookId}`, { + fallback: readLocalReaderPreferences(bookId) + }); + writeLocalReaderPreferences(bookId, preferences); + return preferences; + } catch (error) { + if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto; + return readLocalReaderPreferences(bookId); + } + }, + async saveReaderPreferences(bookId: number, input: ReaderPreferencesDto): Promise { + writeLocalReaderPreferences(bookId, input); + try { + return await request(`/reader/preferences/${bookId}`, { + method: "PUT", + body: JSON.stringify(input), + fallback: input + }); + } catch (error) { + if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto; + return input; + } + }, async continueReading(): Promise { return request("/progress/continue", { fallback: mockContinue }); }, diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 5efbb54..28536d3 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -27,3 +27,12 @@ export type CbzPagesDto = { pageCount: number; pages: Array<{ page: number; name: string }>; }; + +export type ReaderMode = "horizontal" | "vertical"; + +export type ReaderFit = "page" | "width"; + +export type ReaderPreferencesDto = { + mode: ReaderMode; + fit?: ReaderFit; +}; diff --git a/apps/web/src/book/description.test.ts b/apps/web/src/book/description.test.ts new file mode 100644 index 0000000..39f93f7 --- /dev/null +++ b/apps/web/src/book/description.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { cleanBookDescription } from "./description"; + +describe("cleanBookDescription", () => { + it("renders catalog HTML as readable plain text", () => { + expect(cleanBookDescription("

Premier & second.

Suite du texte.

")).toBe( + "Premier & second.\nSuite du texte." + ); + }); + + it("falls back when the description is empty after cleanup", () => { + expect(cleanBookDescription("

")).toBe("Notice absente du catalogue."); + }); +}); diff --git a/apps/web/src/book/description.ts b/apps/web/src/book/description.ts new file mode 100644 index 0000000..83851b3 --- /dev/null +++ b/apps/web/src/book/description.ts @@ -0,0 +1,28 @@ +const blockBreakPattern = /<\/(p|div|section|article|header|footer|blockquote|li|ul|ol|br|h[1-6])>/gi; +const tagPattern = /<[^>]*>/g; + +function decodeEntities(value: string): string { + if (typeof document === "undefined") { + return value + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/gi, "'"); + } + const textarea = document.createElement("textarea"); + textarea.innerHTML = value; + return textarea.value; +} + +export function cleanBookDescription(description?: string | null): string { + if (!description) return "Notice absente du catalogue."; + return decodeEntities(description.replace(blockBreakPattern, "\n").replace(tagPattern, " ")) + .replace(/\r/g, "") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n[ \t]+/g, "\n") + .replace(/[ \t]{2,}/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim() || "Notice absente du catalogue."; +} diff --git a/apps/web/src/pages/BookPage.tsx b/apps/web/src/pages/BookPage.tsx index 9ec0865..79c70b5 100644 --- a/apps/web/src/pages/BookPage.tsx +++ b/apps/web/src/pages/BookPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { BookOpen, LibraryBig, RotateCcw } from "lucide-react"; import type { BookDto, ProgressDto } from "@readabook/shared"; import { api, getApiFallback } from "../api/client"; +import { cleanBookDescription } from "../book/description"; import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui"; import { navigate } from "../router"; @@ -64,7 +65,7 @@ export function BookPage({ bookId }: { bookId: number }) {

{book.title}

{book.author ?? "Auteur inconnu"}

-

{book.description ?? "Notice absente du catalogue."}

+

{cleanBookDescription(book.description)}

{progress && }
-
- {book?.title ?? "Ouverture du lecteur"} - {loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"} -
- {error ? ( - - ) : ( - - )} - - - + void loadBook() : undefined} + mode={preferences.mode} + onModeChange={setMode} + controls={readerControls} + controlsVisible={controlsVisible} + onToggleControls={() => setControlsVisible((visible) => !visible)} + > {!book ? (
{error ?? "Chargement du livre."} @@ -88,12 +98,32 @@ export function ReaderPage({ bookId }: { bookId: number }) {
) : book.format === "pdf" ? ( - + ) : book.format === "cbz" || book.format === "cbr" ? ( - + ) : ( - + )} -
+ ); } diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index e935bb3..4117b7c 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -1,15 +1,20 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { api } from "../api/client"; -import type { CbzPagesDto } from "../api/types"; +import type { CbzPagesDto, ReaderMode } from "../api/types"; +import type { ReaderControls } from "./ReaderShell"; export function CbzReader({ bookId, page, - onPageCommit + mode, + onPageCommit, + onControlsChange }: { bookId: number; page: number; + mode: ReaderMode; onPageCommit: (page: number, pages: number) => void; + onControlsChange: (controls: ReaderControls) => void; }) { const [pages, setPages] = useState(null); const [error, setError] = useState(); @@ -37,13 +42,23 @@ export function CbzReader({ const currentPage = Math.max(1, Math.min(page, pageCount)); const currentName = pages?.pages.find((item) => item.page === currentPage)?.name; - function go(nextPage: number) { + const go = useCallback((nextPage: number) => { setImageError(false); onPageCommit(Math.max(1, Math.min(nextPage, pageCount)), pageCount); - } + }, [onPageCommit, pageCount]); + + useEffect(() => { + onControlsChange({ + canPrevious: !error && !imageError && currentPage > 1, + canNext: !error && !imageError && currentPage < pageCount, + positionLabel: pages ? `${currentPage} / ${pageCount}` : "Ouverture archive", + onPrevious: () => go(currentPage - 1), + onNext: () => go(currentPage + 1) + }); + }, [currentPage, error, go, imageError, onControlsChange, pageCount, pages]); return ( -
+
{error || imageError ? (
{error ?? "Page CBZ indisponible."} @@ -54,17 +69,6 @@ export function CbzReader({ ) : ( {currentName setImageError(true)} /> )} -
- - - {currentPage} / {pageCount} - - -
); } diff --git a/apps/web/src/reader/EpubReader.tsx b/apps/web/src/reader/EpubReader.tsx index 2c089f3..c764313 100644 --- a/apps/web/src/reader/EpubReader.tsx +++ b/apps/web/src/reader/EpubReader.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; -import { ArrowLeft, ArrowRight } from "lucide-react"; import { ReaderError, readerErrorMessage } from "./ReaderError"; +import type { ReaderControls } from "./ReaderShell"; +import type { ReaderMode } from "../api/types"; type FoliateLocation = { cfi?: string; @@ -42,12 +43,16 @@ export function EpubReader({ url, locator, backHref, - onLocatorChange + mode, + onLocatorChange, + onControlsChange }: { url: string; locator?: string; backHref: string; + mode: ReaderMode; onLocatorChange: (locator: string, percent: number) => void; + onControlsChange: (controls: ReaderControls) => void; }) { const hostRef = useRef(null); const viewRef = useRef(null); @@ -56,6 +61,16 @@ export function EpubReader({ const [error, setError] = useState(); const [attempt, setAttempt] = useState(0); + useEffect(() => { + onControlsChange({ + canPrevious: !loading && !error, + canNext: !loading && !error, + positionLabel: loading ? "Ouverture EPUB" : "Lecture integree", + onPrevious: () => void viewRef.current?.goLeft(), + onNext: () => void viewRef.current?.goRight() + }); + }, [error, loading, onControlsChange]); + useEffect(() => { locatorRef.current = locator; }, [locator]); @@ -108,6 +123,11 @@ export function EpubReader({ }; }, [attempt, onLocatorChange, url]); + useEffect(() => { + viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical"); + viewRef.current?.classList.toggle("epub-view-horizontal", mode === "horizontal"); + }, [mode]); + if (error) { return (
@@ -124,24 +144,13 @@ export function EpubReader({ } return ( -
+
{loading && (
Ouverture EPUB
)}
-
- - {loading ? "Chargement" : "Lecture intégrée"} - -
); } diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index 42cf30d..9cb4c70 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -1,26 +1,60 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import * as pdfjs from "pdfjs-dist"; import { ReaderError, readerErrorMessage } from "./ReaderError"; -import { pdfWorkerSrc } from "./pdfWorker"; +import { configurePdfWorker } from "./pdfWorker"; +import type { ReaderControls } from "./ReaderShell"; +import type { ReaderMode } from "../api/types"; -pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc; +configurePdfWorker(pdfjs); export function PdfReader({ url, page, backHref, - onPageCommit + mode, + onPageCommit, + onControlsChange }: { url: string; page: number; backHref: string; + mode: ReaderMode; onPageCommit: (page: number, pages: number) => void; + onControlsChange: (controls: ReaderControls) => void; }) { const canvasRef = useRef(null); + const frameRef = useRef(null); const [pages, setPages] = useState(1); const [error, setError] = useState(); const [loading, setLoading] = useState(true); const [attempt, setAttempt] = useState(0); + const [frameSize, setFrameSize] = useState({ width: 980, height: 900 }); + + const currentPage = Math.max(1, Math.min(page, pages)); + const go = useCallback((nextPage: number) => onPageCommit(Math.max(1, Math.min(nextPage, pages)), pages), [onPageCommit, pages]); + + useEffect(() => { + onControlsChange({ + canPrevious: !loading && !error && currentPage > 1, + canNext: !loading && !error && currentPage < pages, + positionLabel: loading ? "Ouverture PDF" : `${currentPage} / ${pages}`, + onPrevious: () => go(currentPage - 1), + onNext: () => go(currentPage + 1) + }); + }, [currentPage, error, go, loading, onControlsChange, pages]); + + useEffect(() => { + const frame = frameRef.current; + if (!frame) return; + const updateSize = () => { + const rect = frame.getBoundingClientRect(); + setFrameSize({ width: Math.max(320, rect.width), height: Math.max(320, rect.height) }); + }; + updateSize(); + const observer = new ResizeObserver(updateSize); + observer.observe(frame); + return () => observer.disconnect(); + }, []); useEffect(() => { let cancelled = false; @@ -28,6 +62,7 @@ export function PdfReader({ let renderTask: pdfjs.RenderTask | undefined; async function render() { try { + configurePdfWorker(pdfjs); setLoading(true); setError(undefined); loadingTask = pdfjs.getDocument({ url, withCredentials: true }); @@ -37,9 +72,17 @@ export function PdfReader({ const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages))); const canvas = canvasRef.current; if (!canvas) return; - const viewport = pdfPage.getViewport({ scale: Math.min(1.6, window.devicePixelRatio || 1) }); - canvas.width = viewport.width; - canvas.height = viewport.height; + const baseViewport = pdfPage.getViewport({ scale: 1 }); + const fitScale = + mode === "vertical" + ? frameSize.width / baseViewport.width + : Math.min(frameSize.width / baseViewport.width, frameSize.height / baseViewport.height); + const renderScale = Math.max(0.35, Math.min(3, fitScale)) * Math.min(2, window.devicePixelRatio || 1); + const viewport = pdfPage.getViewport({ scale: renderScale }); + canvas.width = Math.floor(viewport.width); + canvas.height = Math.floor(viewport.height); + canvas.style.width = `${Math.floor(viewport.width / Math.min(2, window.devicePixelRatio || 1))}px`; + canvas.style.height = `${Math.floor(viewport.height / Math.min(2, window.devicePixelRatio || 1))}px`; const context = canvas.getContext("2d"); if (!context) return; renderTask = pdfPage.render({ canvas, canvasContext: context, viewport }); @@ -58,10 +101,10 @@ export function PdfReader({ renderTask?.cancel(); void loadingTask?.destroy(); }; - }, [url, page, attempt]); + }, [url, page, attempt, frameSize.height, frameSize.width, mode]); return ( -
+
{error ? ( )} -
- - - {page} / {pages} - - -
); } diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx new file mode 100644 index 0000000..aff8de1 --- /dev/null +++ b/apps/web/src/reader/ReaderShell.tsx @@ -0,0 +1,145 @@ +import { ArrowLeft, ArrowRight, Columns2, RotateCcw, Rows3, Save } from "lucide-react"; +import type { ReactNode } from "react"; +import { ErrorRibbon, Meter } from "../components/ui"; +import { navigate } from "../router"; +import type { ReaderMode } from "../api/types"; + +export type ReaderControls = { + canPrevious: boolean; + canNext: boolean; + positionLabel: string; + onPrevious: () => void; + onNext: () => void; +}; + +type ReaderShellProps = { + title: string; + status: string; + backHref: string; + progress: number; + error?: string; + onRetry?: () => void; + mode: ReaderMode; + onModeChange: (mode: ReaderMode) => void; + controls: ReaderControls; + controlsVisible: boolean; + onToggleControls: () => void; + children: ReactNode; +}; + +export function ReaderShell({ + title, + status, + backHref, + progress, + error, + onRetry, + mode, + onModeChange, + controls, + controlsVisible, + onToggleControls, + children +}: ReaderShellProps) { + return ( +
+
event.stopPropagation()}> + +
+ {title} + {status} +
+
+ + + {error && onRetry ? ( + + ) : ( + + )} +
+
+
event.stopPropagation()}> + + +
+
+ {mode === "horizontal" && ( + <> + + +
+
event.stopPropagation()}> + + {controls.positionLabel} + +
+
+ ); +} diff --git a/apps/web/src/reader/pdfWorker.ts b/apps/web/src/reader/pdfWorker.ts index 54cdf6d..1e4be43 100644 --- a/apps/web/src/reader/pdfWorker.ts +++ b/apps/web/src/reader/pdfWorker.ts @@ -1 +1,17 @@ -export const pdfWorkerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString(); +import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?worker&url"; + +export const pdfWorkerSrc = pdfWorkerUrl; + +let pdfWorkerPort: Worker | null = null; + +export function configurePdfWorker(pdfjs: Pick) { + pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc; + + if (typeof window === "undefined" || !("Worker" in window)) { + return false; + } + + pdfWorkerPort ??= new Worker(pdfWorkerSrc, { type: "module" }); + pdfjs.GlobalWorkerOptions.workerPort = pdfWorkerPort; + return true; +} diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index 87df2c5..6ad5bc2 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { epubFileName } from "./EpubReader"; -import { pdfWorkerSrc } from "./pdfWorker"; +import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker"; import { readerErrorMessage } from "./ReaderError"; describe("reader runtime helpers", () => { @@ -9,10 +9,23 @@ describe("reader runtime helpers", () => { expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub"); }); - it("keeps PDF.js worker 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"); }); + it("configures the PDF.js worker fallback without creating a worker outside the browser", () => { + const pdfjs = { + GlobalWorkerOptions: { + workerPort: null, + workerSrc: "" + } + }; + + expect(configurePdfWorker(pdfjs as unknown as Parameters[0])).toBe(false); + expect(pdfjs.GlobalWorkerOptions.workerPort).toBeNull(); + expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(pdfWorkerSrc); + }); + it("normalizes reader technical errors", () => { expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed"); expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible"); diff --git a/apps/web/src/reader/useReaderPreferences.ts b/apps/web/src/reader/useReaderPreferences.ts new file mode 100644 index 0000000..269efb4 --- /dev/null +++ b/apps/web/src/reader/useReaderPreferences.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "../api/client"; +import type { ReaderMode, ReaderPreferencesDto } from "../api/types"; + +const defaultPreferences: ReaderPreferencesDto = { mode: "horizontal", fit: "page" }; + +export function useReaderPreferences(bookId: number) { + const [preferences, setPreferences] = useState(defaultPreferences); + const [error, setError] = useState(); + + useEffect(() => { + let alive = true; + setError(undefined); + api + .readerPreferences(bookId) + .then((next) => { + if (alive) setPreferences(next); + }) + .catch(() => { + if (alive) setError("Preferences lecteur conservees sur cet appareil."); + }); + return () => { + alive = false; + }; + }, [bookId]); + + const setMode = useCallback( + (mode: ReaderMode) => { + const next = { ...preferences, mode }; + setPreferences(next); + void api.saveReaderPreferences(bookId, next).catch(() => setError("Preferences lecteur conservees sur cet appareil.")); + }, + [bookId, preferences] + ); + + return { preferences, setMode, error }; +} diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index ec74de5..743649d 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -212,6 +212,10 @@ h2 { color: var(--ink-muted); } +.book-description { + white-space: pre-line; +} + .book-card-description { display: -webkit-box; overflow: hidden; @@ -643,13 +647,18 @@ select { .reader-page { display: grid; - gap: 12px; + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: 10px; + height: 100vh; min-height: 100vh; padding: 14px; + overflow: hidden; background: #120e0b; } .reader-topbar { + position: relative; + z-index: 5; justify-content: space-between; padding: 10px; border: 1px solid var(--line); @@ -667,20 +676,65 @@ select { font-size: 0.82rem; } +.reader-toolbar { + display: flex; + align-items: center; + gap: 8px; +} + +.reader-toolbar .icon-only { + width: 40px; + padding: 0; +} + +.reader-toolbar .active { + border-color: rgba(213, 168, 77, 0.72); + color: var(--brass); + background: rgba(213, 168, 77, 0.14); +} + +.reader-status { + position: relative; + z-index: 4; +} + +.reader-stage { + position: relative; + display: grid; + place-items: stretch; + min-height: 0; + min-width: 0; + padding: 0; + overflow: hidden; +} + +.reader-mode-vertical .reader-stage { + overflow-y: auto; + overscroll-behavior: contain; +} + +.reader-content { + display: grid; + min-height: 0; + min-width: 0; +} + .pdf-reader, .epub-reader, .cbz-reader { display: grid; place-items: center; - gap: 10px; - min-height: calc(100vh - 120px); + min-height: 0; + min-width: 0; + width: 100%; + height: 100%; } .epub-host { display: grid; width: min(100%, 980px); - height: calc(100vh - 170px); - min-height: 460px; + height: 100%; + min-height: 0; } .epub-view { @@ -694,8 +748,9 @@ select { .pdf-reader canvas, .cbz-reader img { + display: block; max-width: min(100%, 980px); - max-height: calc(100vh - 170px); + max-height: 100%; border: 1px solid var(--line); border-radius: var(--radius); background: #f7f0df; @@ -706,6 +761,26 @@ select { object-fit: contain; } +.reader-mode-vertical .pdf-reader, +.reader-mode-vertical .cbz-reader { + align-content: start; + padding-bottom: 20px; +} + +.reader-mode-vertical .pdf-reader canvas, +.reader-mode-vertical .cbz-reader img { + width: min(100%, 980px); + height: auto; + max-height: none; +} + +.reader-mode-horizontal .pdf-reader canvas, +.reader-mode-horizontal .cbz-reader img { + width: auto; + height: auto; + object-fit: contain; +} + .reader-fallback { display: flex; flex-wrap: wrap; @@ -717,9 +792,68 @@ select { } .reader-stepper { + position: relative; + z-index: 5; display: flex; align-items: center; + justify-content: center; gap: 12px; + min-height: 46px; + padding: 6px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(38, 26, 18, 0.84); +} + +.reader-stepper span { + min-width: 92px; + color: var(--ink-muted); + text-align: center; +} + +.reader-side-button, +.reader-tap-zone { + position: absolute; + top: 0; + bottom: 0; + z-index: 3; + border: 0; + background: transparent; +} + +.reader-side-button { + display: grid; + place-items: center; + width: 58px; + color: var(--ink); + opacity: 0.66; +} + +.reader-side-button:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.04); +} + +.reader-side-button:disabled, +.reader-tap-zone:disabled { + pointer-events: none; + opacity: 0; +} + +.reader-side-left, +.reader-tap-left { + left: 0; +} + +.reader-side-right, +.reader-tap-right { + right: 0; +} + +.reader-tap-zone { + display: none; + width: 34%; + opacity: 0; } .reader-error { @@ -854,4 +988,80 @@ select { display: grid; grid-template-columns: 1fr 1fr; } + + .reader-page { + padding: 8px; + } + + .reader-topbar { + gap: 8px; + } + + .reader-topbar div { + min-width: 0; + } + + .reader-topbar strong { + overflow: hidden; + max-width: 38vw; + text-overflow: ellipsis; + white-space: nowrap; + } + + .reader-toolbar { + gap: 5px; + } + + .reader-side-button { + display: none; + } + + .reader-tap-zone { + display: block; + } + + .reader-controls-hidden .reader-topbar, + .reader-controls-hidden .reader-status, + .reader-controls-hidden .reader-stepper { + opacity: 0; + pointer-events: none; + } + + .reader-controls-hidden { + grid-template-rows: 0 0 minmax(0, 1fr) 0; + gap: 0; + } + + .reader-controls-hidden .reader-topbar, + .reader-controls-hidden .reader-status, + .reader-controls-hidden .reader-stepper { + overflow: hidden; + min-height: 0; + padding-top: 0; + padding-bottom: 0; + } + + .reader-topbar, + .reader-status, + .reader-stepper { + transition: opacity 0.16s ease; + } + + .reader-stepper { + gap: 8px; + } + + .reader-stepper .ghost-button { + flex: 1 1 0; + min-width: 0; + } + + .epub-host { + width: 100%; + } + + .reader-mode-vertical .pdf-reader canvas, + .reader-mode-vertical .cbz-reader img { + width: 100%; + } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9243ebd..0151b80 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -128,6 +128,19 @@ export const UpdateProgressSchema = z.object({ }); export type UpdateProgressDto = z.infer; +export const ReaderPreferencesSchema = z.object({ + mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).default("paged"), + fit: z.enum(["page", "width", "height", "auto"]).nullable().default(null), + updatedAt: z.string().optional() +}); +export type ReaderPreferencesDto = z.infer; + +export const UpdateReaderPreferencesSchema = z.object({ + mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).optional(), + fit: z.enum(["page", "width", "height", "auto"]).nullable().optional() +}); +export type UpdateReaderPreferencesDto = z.infer; + export const JobSchema = z.object({ id: z.number().int().positive(), type: z.string(),