access-calibre 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # access-calibre
2
+
3
+ > [!NOTE]
4
+ > This package has been entirely vibe-coded by Junie in JetBrains IntelliJ and not human checked.
5
+
6
+ A JavaScript library to connect to a local Calibre Content Server and grab portions of ebooks.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ npm install access-calibre
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ You can use it as a library in your project:
17
+
18
+ ```javascript
19
+ const CalibreClient = require('./src/client');
20
+
21
+ const client = new CalibreClient('http://localhost:8080', 'username', 'password');
22
+
23
+ async function example() {
24
+ // List libraries
25
+ const libraries = await client.getLibraries();
26
+ const libraryId = Object.keys(libraries)[0];
27
+
28
+ // List books
29
+ const books = await client.getBooks(libraryId);
30
+
31
+ // Get EPUB contents
32
+ const bookId = books.book_ids[0];
33
+ const files = await client.getEpubContents(libraryId, bookId);
34
+ console.log('Files in EPUB:', files);
35
+
36
+ // Get a specific file (e.g., a chapter) from the EPUB
37
+ const html = await client.getEpubFile(libraryId, bookId, 'index_split_001.html');
38
+ console.log(html);
39
+ }
40
+ ```
41
+
42
+ ## API
43
+
44
+ ### `new CalibreClient(baseUrl, username, password)`
45
+ Initialize the client.
46
+
47
+ ### `client.getLibraries()`
48
+ Returns an object containing information about available libraries.
49
+
50
+ ### `client.getBooks(libraryId, limit, offset)`
51
+ Returns a list of book IDs and their metadata for the specified library.
52
+
53
+ ### `client.getBookMetadata(libraryId, bookId)`
54
+ Returns detailed metadata for a specific book.
55
+
56
+ ### `client.downloadBook(libraryId, bookId, format)`
57
+ Downloads the book in the specified format (e.g., 'EPUB', 'PDF') and returns a Buffer.
58
+
59
+ ### `client.getEpubContents(libraryId, bookId)`
60
+ Returns a list of file paths inside the EPUB file.
61
+
62
+ ### `client.getTOC(libraryId, bookId)`
63
+ Returns an array of `{ title, path }` objects representing the book's Table of Contents.
64
+
65
+ ### `client.getChapters(libraryId, bookId)`
66
+ Returns an array of `{ title, path }` objects representing the book's chapters in their linear reading order (as defined in the EPUB spine).
67
+
68
+ ### `client.getEpubFile(libraryId, bookId, filePath, responseType)`
69
+ Extracts a specific file from the EPUB and returns its content as a string (default) or Buffer (if `responseType` is `'buffer'`).
70
+
71
+ ## Scripts
72
+
73
+ ### `get-chapter.js`
74
+ Extracts the XML/HTML content of a chapter to the console.
75
+ ```bash
76
+ node get-chapter.js <book_id_or_title> <chapter_index_or_title>
77
+ ```
78
+
79
+ ### `render-chapter.js`
80
+ Renders a specific page from a chapter to a PNG image using Playwright.
81
+ ```bash
82
+ node render-chapter.js <book_id_or_title> <chapter_index_or_title> [output.png] [--page <number>]
83
+ ```
84
+ - `--page <number>`: Optional. Specifies which page of the chapter to render (default is 1).
85
+ - If no output filename is provided, it defaults to `chapter_render.png`.
86
+
87
+ Note: You may need to run `npx playwright install chromium` before using this script.
88
+
89
+ ## MCP Server
90
+
91
+ This library includes an MCP (Model Context Protocol) server that allows LLMs to interact with your Calibre library.
92
+
93
+ ### Tools Provided:
94
+ - `list_libraries`: List available libraries.
95
+ - `search_books`: Search for books by title or author across all libraries.
96
+ - `list_books`: List all books in a library.
97
+ - `list_chapters`: List the chapters of a book in reading order.
98
+ - `get_chapter_content`: Retrieve the HTML content of a specific chapter.
99
+ - `render_chapter_page`: Render a specific page of a chapter as a PNG image (useful for LLMs with vision capabilities). It also returns the total number of pages in the chapter.
100
+ - `get_book_cover`: Retrieve the cover image of a book as a PNG image.
101
+ - `get_epub_file`: Retrieve any file from the EPUB (e.g. CSS, images).
102
+
103
+ ### Configuration
104
+ The MCP server can be configured via environment variables:
105
+ - `CALIBRE_URL`: The URL of your Calibre Content Server (default: `http://[::1]:8080/`).
106
+ - `CALIBRE_USERNAME`: Optional username for authentication.
107
+ - `CALIBRE_PASSWORD`: Optional password for authentication.
108
+
109
+ ### Running the MCP Server
110
+
111
+ #### Claude Desktop
112
+
113
+ You can run the MCP server directly using `npx` (useful for Claude Desktop or other MCP clients):
114
+
115
+ ```bash
116
+ npx access-calibre
117
+ ```
118
+
119
+ #### LM Studio
120
+
121
+ To use this MCP server in LM Studio:
122
+
123
+ 1. Open **LM Studio**.
124
+ 2. Click on the **Tools** icon (puzzle piece) in the left sidebar.
125
+ 3. Click **+ Add Tool**.
126
+ 4. Choose **MCP Server**.
127
+ 5. Give it a name (e.g., `access-calibre`).
128
+ 6. For **Command**, enter `npx`.
129
+ 7. For **Arguments**, enter `access-calibre`.
130
+ 8. Add any required environment variables (like `CALIBRE_URL`) in the **Environment Variables** section.
131
+ 9. Click **Add Tool**.
132
+
133
+ Alternatively, you can add it to your `mcp-config.json` (usually found in `~/.lmstudio/mcp-config.json`):
134
+
135
+ ```json
136
+ {
137
+ "mcpServers": {
138
+ "access-calibre": {
139
+ "command": "npx",
140
+ "args": ["access-calibre"],
141
+ "env": {
142
+ "CALIBRE_URL": "http://localhost:8080/"
143
+ }
144
+ }
145
+ }
146
+ }
147
+ ```
148
+
149
+ #### Local Installation
150
+
151
+ ```bash
152
+ npm run start:mcp
153
+ ```
154
+
155
+ Or directly via Node:
156
+ ```bash
157
+ CALIBRE_URL=http://localhost:8080 node mcp-server.js
158
+ ```
159
+
160
+ ## Running Tests
161
+
162
+ ```bash
163
+ npm test
164
+ ```
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ const CalibreClient = require('./src/client');
2
+
3
+ module.exports = CalibreClient;
4
+
package/mcp-server.js ADDED
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env node
2
+ const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
3
+ const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
4
+ const {
5
+ CallToolRequestSchema,
6
+ ErrorCode,
7
+ ListToolsRequestSchema,
8
+ McpError,
9
+ } = require("@modelcontextprotocol/sdk/types.js");
10
+ const CalibreClient = require("./index");
11
+
12
+ const CALIBRE_URL = process.env.CALIBRE_URL || "http://[::1]:8080/";
13
+ const CALIBRE_USERNAME = process.env.CALIBRE_USERNAME || null;
14
+ const CALIBRE_PASSWORD = process.env.CALIBRE_PASSWORD || null;
15
+
16
+ const client = new CalibreClient(CALIBRE_URL, CALIBRE_USERNAME, CALIBRE_PASSWORD);
17
+
18
+ const server = new Server(
19
+ {
20
+ name: "calibre-reader",
21
+ version: "1.0.0",
22
+ },
23
+ {
24
+ capabilities: {
25
+ tools: {},
26
+ },
27
+ }
28
+ );
29
+
30
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
31
+ return {
32
+ tools: [
33
+ {
34
+ name: "list_libraries",
35
+ description: "List available Calibre libraries",
36
+ inputSchema: {
37
+ type: "object",
38
+ properties: {
39
+ // Some MCP clients/LLMs struggle with empty properties
40
+ _config: {
41
+ type: "object",
42
+ description: "Optional configuration (unused)",
43
+ },
44
+ },
45
+ required: [],
46
+ },
47
+ },
48
+ {
49
+ name: "search_books",
50
+ description: "Search for books across all libraries by title or author fragment",
51
+ inputSchema: {
52
+ type: "object",
53
+ properties: {
54
+ query: {
55
+ type: "string",
56
+ description: "Title or author fragment to search for",
57
+ },
58
+ },
59
+ required: ["query"],
60
+ },
61
+ },
62
+ {
63
+ name: "list_books",
64
+ description: "List books in a specific library",
65
+ inputSchema: {
66
+ type: "object",
67
+ properties: {
68
+ libraryId: {
69
+ type: "string",
70
+ description: "The ID of the library",
71
+ },
72
+ limit: {
73
+ type: "number",
74
+ description: "Maximum number of books to return (default 100)",
75
+ },
76
+ offset: {
77
+ type: "number",
78
+ description: "Offset for pagination (default 0)",
79
+ },
80
+ },
81
+ required: ["libraryId"],
82
+ },
83
+ },
84
+ {
85
+ name: "list_chapters",
86
+ description: "List chapters (reading order) of a specific book",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ libraryId: {
91
+ type: "string",
92
+ description: "The ID of the library",
93
+ },
94
+ bookId: {
95
+ type: "number",
96
+ description: "The ID of the book",
97
+ },
98
+ },
99
+ required: ["libraryId", "bookId"],
100
+ },
101
+ },
102
+ {
103
+ name: "get_chapter_content",
104
+ description: "Get the HTML content of a specific chapter",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ libraryId: {
109
+ type: "string",
110
+ description: "The ID of the library",
111
+ },
112
+ bookId: {
113
+ type: "number",
114
+ description: "The ID of the book",
115
+ },
116
+ path: {
117
+ type: "string",
118
+ description: "The internal file path of the chapter (from list_chapters)",
119
+ },
120
+ },
121
+ required: ["libraryId", "bookId", "path"],
122
+ },
123
+ },
124
+ {
125
+ name: "render_chapter_page",
126
+ description: "Render a specific page of a chapter as a PNG image",
127
+ inputSchema: {
128
+ type: "object",
129
+ properties: {
130
+ libraryId: {
131
+ type: "string",
132
+ description: "The ID of the library",
133
+ },
134
+ bookId: {
135
+ type: "number",
136
+ description: "The ID of the book",
137
+ },
138
+ path: {
139
+ type: "string",
140
+ description: "The internal file path of the chapter (from list_chapters)",
141
+ },
142
+ page: {
143
+ type: "number",
144
+ description: "The page number to render (default 1)",
145
+ },
146
+ },
147
+ required: ["libraryId", "bookId", "path"],
148
+ },
149
+ },
150
+ {
151
+ name: "get_book_cover",
152
+ description: "Get the cover image of a book as a PNG",
153
+ inputSchema: {
154
+ type: "object",
155
+ properties: {
156
+ libraryId: {
157
+ type: "string",
158
+ description: "The ID of the library",
159
+ },
160
+ bookId: {
161
+ type: "number",
162
+ description: "The ID of the book",
163
+ },
164
+ },
165
+ required: ["libraryId", "bookId"],
166
+ },
167
+ },
168
+ {
169
+ name: "get_epub_file",
170
+ description: "Retrieve any file from the EPUB (e.g. CSS, images)",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {
174
+ libraryId: {
175
+ type: "string",
176
+ description: "The ID of the library",
177
+ },
178
+ bookId: {
179
+ type: "number",
180
+ description: "The ID of the book",
181
+ },
182
+ path: {
183
+ type: "string",
184
+ description: "The internal file path in the EPUB",
185
+ },
186
+ },
187
+ required: ["libraryId", "bookId", "path"],
188
+ },
189
+ },
190
+ ],
191
+ };
192
+ });
193
+
194
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
195
+ const { name, arguments: args } = request.params;
196
+
197
+ try {
198
+ switch (name) {
199
+ case "list_libraries": {
200
+ const libraries = await client.getLibraries();
201
+ return {
202
+ content: [{ type: "text", text: JSON.stringify(libraries, null, 2) }],
203
+ };
204
+ }
205
+
206
+ case "search_books": {
207
+ if (!args.query) {
208
+ throw new McpError(ErrorCode.InvalidParams, "Query is required");
209
+ }
210
+ const query = args.query.toLowerCase();
211
+ const libraries = await client.getLibraries();
212
+ const results = [];
213
+
214
+ for (const libraryId of Object.keys(libraries)) {
215
+ const books = await client.getBooks(libraryId);
216
+ if (Array.isArray(books)) {
217
+ for (const book of books) {
218
+ const title = (book.title || "").toLowerCase();
219
+ const authors = Array.isArray(book.authors)
220
+ ? book.authors.join(", ").toLowerCase()
221
+ : (book.authors || "").toLowerCase();
222
+
223
+ if (title.includes(query) || authors.includes(query)) {
224
+ results.push({
225
+ libraryId,
226
+ libraryName: libraries[libraryId],
227
+ bookId: book.id || book.application_id,
228
+ title: book.title,
229
+ authors: book.authors
230
+ });
231
+ }
232
+ }
233
+ }
234
+ }
235
+ return {
236
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
237
+ };
238
+ }
239
+
240
+ case "list_books": {
241
+ if (!args.libraryId) {
242
+ throw new McpError(ErrorCode.InvalidParams, "libraryId is required");
243
+ }
244
+ const books = await client.getBooks(args.libraryId, args.limit || 100, args.offset || 0);
245
+ return {
246
+ content: [{ type: "text", text: JSON.stringify(books, null, 2) }],
247
+ };
248
+ }
249
+
250
+ case "list_chapters": {
251
+ if (!args.libraryId || !args.bookId) {
252
+ throw new McpError(ErrorCode.InvalidParams, "libraryId and bookId are required");
253
+ }
254
+ const chapters = await client.getChapters(args.libraryId, args.bookId);
255
+ return {
256
+ content: [{ type: "text", text: JSON.stringify(chapters, null, 2) }],
257
+ };
258
+ }
259
+
260
+ case "get_chapter_content": {
261
+ if (!args.libraryId || !args.bookId || !args.path) {
262
+ throw new McpError(ErrorCode.InvalidParams, "libraryId, bookId and path are required");
263
+ }
264
+ const content = await client.getEpubFile(args.libraryId, args.bookId, args.path);
265
+ return {
266
+ content: [{ type: "text", text: content }],
267
+ };
268
+ }
269
+
270
+ case "render_chapter_page": {
271
+ if (!args.libraryId || !args.bookId || !args.path) {
272
+ throw new McpError(ErrorCode.InvalidParams, "libraryId, bookId and path are required");
273
+ }
274
+ const { buffer, totalPages } = await client.renderChapterPage(
275
+ args.libraryId,
276
+ args.bookId,
277
+ args.path,
278
+ args.page || 1
279
+ );
280
+ return {
281
+ content: [
282
+ {
283
+ type: "text",
284
+ text: `Page ${args.page || 1} of ${totalPages}`,
285
+ },
286
+ {
287
+ type: "image",
288
+ data: buffer.toString("base64"),
289
+ mimeType: "image/png",
290
+ },
291
+ ],
292
+ };
293
+ }
294
+
295
+ case "get_book_cover": {
296
+ if (!args.libraryId || !args.bookId) {
297
+ throw new McpError(ErrorCode.InvalidParams, "libraryId and bookId are required");
298
+ }
299
+ const buffer = await client.getBookCover(args.libraryId, args.bookId);
300
+ return {
301
+ content: [
302
+ {
303
+ type: "image",
304
+ data: buffer.toString("base64"),
305
+ mimeType: "image/png",
306
+ },
307
+ {
308
+ type: "text",
309
+ text: `Cover image for book ID ${args.bookId} in library ${args.libraryId}`,
310
+ }
311
+ ],
312
+ };
313
+ }
314
+
315
+ case "get_epub_file": {
316
+ if (!args.libraryId || !args.bookId || !args.path) {
317
+ throw new McpError(ErrorCode.InvalidParams, "libraryId, bookId and path are required");
318
+ }
319
+
320
+ const isImage = /\.(png|jpe?g|gif|webp)$/i.test(args.path);
321
+ const responseType = isImage ? 'buffer' : 'text';
322
+
323
+ const content = await client.getEpubFile(args.libraryId, args.bookId, args.path, responseType);
324
+
325
+ if (isImage) {
326
+ const mimeType = args.path.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
327
+ return {
328
+ content: [
329
+ {
330
+ type: "image",
331
+ data: content.toString("base64"),
332
+ mimeType: mimeType,
333
+ },
334
+ ],
335
+ };
336
+ }
337
+
338
+ return {
339
+ content: [{ type: "text", text: content }],
340
+ };
341
+ }
342
+
343
+ default:
344
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
345
+ }
346
+ } catch (error) {
347
+ if (error instanceof McpError) {
348
+ throw error;
349
+ }
350
+ return {
351
+ content: [{ type: "text", text: `Error: ${error.message}` }],
352
+ isError: true,
353
+ };
354
+ }
355
+ });
356
+
357
+ async function main() {
358
+ const transport = new StdioServerTransport();
359
+ await server.connect(transport);
360
+ console.error("Calibre Reader MCP server running on stdio");
361
+ }
362
+
363
+ main().catch((error) => {
364
+ console.error("Server error:", error);
365
+ process.exit(1);
366
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "access-calibre",
3
+ "version": "1.0.0",
4
+ "description": "A JavaScript library and MCP server to connect to a local Calibre Content Server and grab portions of ebooks.",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "access-calibre": "./mcp-server.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "mcp-server.js",
12
+ "src",
13
+ "README.md",
14
+
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "jest",
19
+ "start:mcp": "node mcp-server.js"
20
+ },
21
+ "keywords": ["mcp", "calibre", "llm", "book", "books"],
22
+ "author": "kybernetikos <me@kybernetikos.com>",
23
+ "license": "MIT",
24
+ "type": "commonjs",
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.25.3",
27
+ "adm-zip": "^0.5.16",
28
+ "axios": "^1.13.4",
29
+ "chromium": "^3.0.3",
30
+ "playwright-core": "^1.58.1"
31
+ },
32
+ "devDependencies": {
33
+ "jest": "^30.2.0",
34
+ "nock": "^14.0.10"
35
+ }
36
+ }
package/src/client.js ADDED
@@ -0,0 +1,375 @@
1
+ const axios = require('axios');
2
+ const AdmZip = require('adm-zip');
3
+ const { chromium } = require('playwright-core');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ class CalibreClient {
9
+ constructor(baseUrl, username = null, password = null) {
10
+ this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
11
+ this.auth = (username && password) ? { username, password } : null;
12
+ this.client = axios.create({
13
+ baseURL: this.baseUrl,
14
+ auth: this.auth
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Get a list of libraries available on the server.
20
+ */
21
+ async getLibraries() {
22
+ const response = await this.client.get('/interface-data/init');
23
+ return response.data.library_info || response.data.library_map || {};
24
+ }
25
+
26
+ /**
27
+ * Get books from a specific library.
28
+ * @param {string} libraryId
29
+ * @param {number} limit
30
+ * @param {number} offset
31
+ */
32
+ async getBooks(libraryId, limit = 100, offset = 0) {
33
+ // Calibre's JSON API often uses /ajax/books/
34
+ const response = await this.client.get(`/ajax/books/${libraryId}`, {
35
+ params: {
36
+ num: limit,
37
+ start: offset
38
+ }
39
+ });
40
+
41
+ // Some versions of Calibre return an object with book IDs as keys,
42
+ // others return an object with a 'books' array.
43
+ // We try to normalize this to an array of books.
44
+ if (response.data && response.data.books) {
45
+ return response.data.books;
46
+ } else if (response.data && typeof response.data === 'object') {
47
+ const values = Object.values(response.data);
48
+ const books = values.filter(item => typeof item === 'object' && item !== null && (item.id !== undefined || item.application_id !== undefined));
49
+
50
+ if (books.length > 0) {
51
+ return books;
52
+ }
53
+ }
54
+ return response.data;
55
+ }
56
+
57
+ /**
58
+ * Get detailed metadata for a specific book.
59
+ */
60
+ async getBookMetadata(libraryId, bookId) {
61
+ const response = await this.client.get(`/ajax/book/${bookId}/${libraryId}`);
62
+ return response.data;
63
+ }
64
+
65
+ /**
66
+ * Get the formats available for a book.
67
+ */
68
+ async getBookFormats(libraryId, bookId) {
69
+ const metadata = await this.getBookMetadata(libraryId, bookId);
70
+ return metadata.formats;
71
+ }
72
+
73
+ /**
74
+ * Download a specific format of a book.
75
+ * Note: This returns a Buffer.
76
+ */
77
+ async downloadBook(libraryId, bookId, format) {
78
+ const url = `/get/${format}/${bookId}/${libraryId}`;
79
+ const response = await this.client.get(url, { responseType: 'arraybuffer' });
80
+ return Buffer.from(response.data);
81
+ }
82
+
83
+ /**
84
+ * Get the cover image of a book.
85
+ * Returns a Buffer.
86
+ */
87
+ async getBookCover(libraryId, bookId) {
88
+ const url = `/get/cover/${bookId}/${libraryId}`;
89
+ const response = await this.client.get(url, { responseType: 'arraybuffer' });
90
+ return Buffer.from(response.data);
91
+ }
92
+
93
+ /**
94
+ * Get a list of files inside an EPUB book.
95
+ */
96
+ async getEpubContents(libraryId, bookId) {
97
+ const buffer = await this.getEpubBuffer(libraryId, bookId);
98
+ const zip = new AdmZip(buffer);
99
+ return zip.getEntries().map(entry => entry.entryName);
100
+ }
101
+
102
+ /**
103
+ * Extract a specific file from an EPUB.
104
+ * Returns the content as a string or Buffer depending on the format.
105
+ */
106
+ async getEpubFile(libraryId, bookId, filePath, responseType = 'text') {
107
+ const buffer = await this.getEpubBuffer(libraryId, bookId);
108
+ const zip = new AdmZip(buffer);
109
+ const entry = zip.getEntry(filePath);
110
+ if (!entry) throw new Error(`File ${filePath} not found in EPUB`);
111
+
112
+ if (responseType === 'buffer') {
113
+ return entry.getData();
114
+ }
115
+ return zip.readAsText(entry);
116
+ }
117
+ /**
118
+ * Get a Buffer of the entire EPUB file.
119
+ */
120
+ async getEpubBuffer(libraryId, bookId) {
121
+ const formats = await this.getBookFormats(libraryId, bookId);
122
+ const epubFormat = formats.find(f => f.toUpperCase() === 'EPUB');
123
+ if (!epubFormat) throw new Error(`Book ${bookId} does not have an EPUB format`);
124
+
125
+ return await this.downloadBook(libraryId, bookId, epubFormat);
126
+ }
127
+
128
+ /**
129
+ * Parse Manifest
130
+ */
131
+ _parseManifest(opfXml, opfDir) {
132
+ const manifest = {};
133
+ const itemRegex = /<item\s+[^>]*href\s*=\s*"([^"]+)"[^>]*id\s*=\s*"([^"]+)"|<item\s+[^>]*id\s*=\s*"([^"]+)"[^>]*href\s*=\s*"([^"]+)"/gi;
134
+ let itemMatch;
135
+ while ((itemMatch = itemRegex.exec(opfXml)) !== null) {
136
+ const id = itemMatch[2] || itemMatch[3];
137
+ const href = itemMatch[1] || itemMatch[4];
138
+ // Decode URI components in href (e.g. %20 to space)
139
+ let decodedHref = href;
140
+ try {
141
+ decodedHref = decodeURIComponent(href);
142
+ } catch (e) {
143
+ // Ignore decoding errors
144
+ }
145
+ manifest[id] = this._normalizePath(opfDir + decodedHref);
146
+ }
147
+ return manifest;
148
+ }
149
+
150
+ /**
151
+ * Normalize path (remove ./ and handle ../)
152
+ */
153
+ _normalizePath(filePath) {
154
+ // Simple normalization for internal EPUB paths
155
+ const parts = filePath.split('/');
156
+ const stack = [];
157
+ for (const part of parts) {
158
+ if (part === '.' || part === '') continue;
159
+ if (part === '..') {
160
+ if (stack.length > 0) stack.pop();
161
+ } else {
162
+ stack.push(part);
163
+ }
164
+ }
165
+ return stack.join('/');
166
+ }
167
+
168
+ /**
169
+ * Build chapters list
170
+ */
171
+ async getChapters(libraryId, bookId) {
172
+ const buffer = await this.getEpubBuffer(libraryId, bookId);
173
+ const zip = new AdmZip(buffer);
174
+
175
+ // 1. Find the OPF file
176
+ const containerEntry = zip.getEntry('META-INF/container.xml');
177
+ if (!containerEntry) throw new Error('EPUB missing META-INF/container.xml');
178
+ const containerXml = zip.readAsText(containerEntry);
179
+ const fullPathMatch = containerXml.match(/full-path="([^"]+)"/);
180
+ if (!fullPathMatch) throw new Error('Could not find root file in container.xml');
181
+ const opfPath = fullPathMatch[1];
182
+ const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
183
+
184
+ const opfEntry = zip.getEntry(opfPath);
185
+ if (!opfEntry) throw new Error(`Could not find OPF file at ${opfPath}`);
186
+ const opfXml = zip.readAsText(opfEntry);
187
+
188
+ // 2. Parse Manifest
189
+ const manifest = this._parseManifest(opfXml, opfDir);
190
+
191
+ // 3. Parse Spine
192
+ const spine = [];
193
+ const itemrefRegex = /<itemref\s+[^>]*idref\s*=\s*"([^"]+)"/gi;
194
+ let itemrefMatch;
195
+ while ((itemrefMatch = itemrefRegex.exec(opfXml)) !== null) {
196
+ const idref = itemrefMatch[1];
197
+ if (manifest[idref]) {
198
+ spine.push(manifest[idref]);
199
+ }
200
+ }
201
+
202
+ // 4. Get TOC for titles
203
+ const toc = await this.getTOC(libraryId, bookId);
204
+ const tocMap = {};
205
+ toc.forEach(item => {
206
+ const normalizedPath = this._normalizePath(item.path);
207
+ if (!tocMap[normalizedPath]) {
208
+ tocMap[normalizedPath] = item.title;
209
+ }
210
+ });
211
+
212
+ // 5. Build chapters list
213
+ return spine.map(chapterPath => ({
214
+ title: tocMap[chapterPath] || 'No heading',
215
+ path: chapterPath
216
+ }));
217
+ }
218
+
219
+ /**
220
+ * Renders a specific page of a chapter to a PNG Buffer.
221
+ */
222
+ async renderChapterPage(libraryId, bookId, chapterPath, pageNumber = 1, width = 800, height = 1000) {
223
+ const epubBuffer = await this.getEpubBuffer(libraryId, bookId);
224
+ const zip = new AdmZip(epubBuffer);
225
+
226
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'calibre-render-'));
227
+
228
+ try {
229
+ // Only extract the requested chapter and its immediate dependencies if possible?
230
+ // Actually, EPUBs often have CSS/images in other directories.
231
+ // Extracting all is safer for rendering, but let's ensure we only extract once if possible.
232
+ zip.extractAllTo(tempDir, true);
233
+
234
+ const htmlPath = path.join(tempDir, chapterPath);
235
+ if (!fs.existsSync(htmlPath)) {
236
+ // Try to find it if path is relative or slightly different
237
+ throw new Error(`Extracted chapter file not found at expected path: ${chapterPath}`);
238
+ }
239
+
240
+ const browser = await chromium.launch({
241
+ args: ['--disable-web-security'] // Allow loading local files
242
+ });
243
+ try {
244
+ const page = await browser.newPage();
245
+ await page.setViewportSize({ width, height });
246
+
247
+ // Use a proper file:// URL
248
+ const fileUrl = `file://${path.resolve(htmlPath)}`;
249
+ await page.goto(fileUrl, { waitUntil: 'networkidle' });
250
+
251
+ const totalPages = await page.evaluate((h) => {
252
+ return Math.ceil(document.documentElement.scrollHeight / h) || 1;
253
+ }, height);
254
+
255
+ if (pageNumber > 1) {
256
+ await page.evaluate(({ n, h }) => {
257
+ window.scrollTo(0, (n - 1) * h);
258
+ }, { n: pageNumber, h: height });
259
+
260
+ // Give it a moment to scroll and render
261
+ await new Promise(resolve => setTimeout(resolve, 500));
262
+ }
263
+
264
+ const screenshotBuffer = await page.screenshot({ fullPage: false });
265
+ return {
266
+ buffer: screenshotBuffer,
267
+ totalPages: totalPages
268
+ };
269
+ } finally {
270
+ await browser.close();
271
+ }
272
+ } finally {
273
+ try {
274
+ if (fs.existsSync(tempDir)) {
275
+ fs.rmSync(tempDir, { recursive: true, force: true });
276
+ }
277
+ } catch (err) {
278
+ console.error(`Failed to clean up temp directory ${tempDir}: ${err.message}`);
279
+ }
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Get the Table of Contents (TOC) from an EPUB.
285
+ * Returns an array of { title, path } objects.
286
+ */
287
+ async getTOC(libraryId, bookId) {
288
+ const buffer = await this.getEpubBuffer(libraryId, bookId);
289
+ const zip = new AdmZip(buffer);
290
+ const entries = zip.getEntries();
291
+
292
+ // 1. Find the OPF file to find the TOC file
293
+ const containerEntry = zip.getEntry('META-INF/container.xml');
294
+ if (!containerEntry) throw new Error('EPUB missing META-INF/container.xml');
295
+ const containerXml = zip.readAsText(containerEntry);
296
+ const fullPathMatch = containerXml.match(/full-path="([^"]+)"/);
297
+ if (!fullPathMatch) throw new Error('Could not find root file in container.xml');
298
+ const opfPath = fullPathMatch[1];
299
+ const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
300
+
301
+ const opfEntry = zip.getEntry(opfPath);
302
+ if (!opfEntry) throw new Error(`Could not find OPF file at ${opfPath}`);
303
+ const opfXml = zip.readAsText(opfEntry);
304
+
305
+ // Try to find TOC in NCX (EPUB 2) or Nav (EPUB 3)
306
+ // Check for NCX first
307
+ const ncxMatch = opfXml.match(/id="([^"]+)"[^>]+media-type="application\/x-dtbncx\+xml"/i) ||
308
+ opfXml.match(/media-type="application\/x-dtbncx\+xml"[^>]+id="([^"]+)"/i);
309
+
310
+ let toc = [];
311
+
312
+ if (ncxMatch) {
313
+ const ncxId = ncxMatch[1];
314
+ const ncxHrefMatch = opfXml.match(new RegExp(`id="${ncxId}"[^>]+href="([^"]+)"`)) ||
315
+ opfXml.match(new RegExp(`href="([^"]+)"[^>]+id="${ncxId}"`));
316
+ if (ncxHrefMatch) {
317
+ const ncxPath = opfDir + ncxHrefMatch[1];
318
+ const ncxEntry = zip.getEntry(ncxPath);
319
+ if (ncxEntry) {
320
+ const ncxXml = zip.readAsText(ncxEntry);
321
+ // Very basic NCX parsing using regex
322
+ const navPointRegex = /<navPoint[^>]*>[\s\S]*?<navLabel>[\s\S]*?<text>([\s\S]*?)<\/text>[\s\S]*?<\/navLabel>[\s\S]*?<content src="([^"]+)"/g;
323
+ let match;
324
+ while ((match = navPointRegex.exec(ncxXml)) !== null) {
325
+ let label = match[1].trim();
326
+ let href = match[2];
327
+ // remove anchors
328
+ if (href.includes('#')) href = href.split('#')[0];
329
+
330
+ const fullPath = this._normalizePath(opfDir + href);
331
+ // Avoid duplicates if multiple navPoints point to same file, but keep first (usually better label)
332
+ if (!toc.find(t => t.path === fullPath)) {
333
+ toc.push({
334
+ title: label,
335
+ path: fullPath
336
+ });
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ // If no TOC found yet, try EPUB 3 Nav
344
+ if (toc.length === 0) {
345
+ const navMatch = opfXml.match(/properties="[^"]*nav[^"]*"[^>]+href="([^"]+)"/i) ||
346
+ opfXml.match(/href="([^"]+)"[^>]+properties="[^"]*nav[^"]*"/i);
347
+ if (navMatch) {
348
+ const navPath = this._normalizePath(opfDir + navMatch[1]);
349
+ const navEntry = zip.getEntry(navPath);
350
+ if (navEntry) {
351
+ const navXml = zip.readAsText(navEntry);
352
+ // Very basic Nav parsing
353
+ const linkRegex = /<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
354
+ let match;
355
+ while ((match = linkRegex.exec(navXml)) !== null) {
356
+ let href = match[1];
357
+ if (href.includes('#')) href = href.split('#')[0];
358
+ const fullPath = this._normalizePath(opfDir + href);
359
+ const label = match[2].replace(/<[^>]+>/g, '').trim();
360
+ if (!toc.find(t => t.path === fullPath)) {
361
+ toc.push({
362
+ title: label,
363
+ path: fullPath
364
+ });
365
+ }
366
+ }
367
+ }
368
+ }
369
+ }
370
+
371
+ return toc;
372
+ }
373
+ }
374
+
375
+ module.exports = CalibreClient;