diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue
index fce269396..548ff74b7 100644
--- a/client/components/readers/ComicReader.vue
+++ b/client/components/readers/ComicReader.vue
@@ -20,7 +20,7 @@
more
-
+
download
@@ -45,7 +45,7 @@
-
![]()
+
@@ -57,17 +57,11 @@
diff --git a/server/Auth.js b/server/Auth.js
index f63e84460..085ed14d4 100644
--- a/server/Auth.js
+++ b/server/Auth.js
@@ -18,7 +18,11 @@ const { escapeRegExp } = require('./utils')
class Auth {
constructor() {
const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
- this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]
+ this.ignorePatterns = [
+ new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`),
+ new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`),
+ new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/comic-page/[0-9]+`)
+ ]
/** @type {import('express-rate-limit').RateLimitRequestHandler} */
this.authRateLimiter = RateLimiterFactory.getAuthRateLimiter()
diff --git a/server/Server.js b/server/Server.js
index d6f748a1e..3e18857a0 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -164,6 +164,8 @@ class Server {
await this.cleanUserData() // Remove invalid user item progress
await CacheManager.ensureCachePaths()
+ const ComicCacheManager = require('./managers/ComicCacheManager')
+ await ComicCacheManager.ensureCachePaths()
await ShareManager.init()
await this.backupManager.init()
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index 5247dbb06..a139aa36e 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -1157,6 +1157,145 @@ class LibraryItemController {
res.sendStatus(200)
}
+ /**
+ * GET api/items/:id/comic-pages/:fileid?
+ * Get comic metadata (page list) without downloading the whole file
+ * fileid is optional - defaults to primary ebook
+ *
+ * @param {LibraryItemControllerRequest} req
+ * @param {Response} res
+ */
+ async getComicPages(req, res) {
+ const ComicCacheManager = require('../managers/ComicCacheManager')
+
+ let ebookFile = null
+ if (req.params.fileid) {
+ ebookFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid)
+ if (!ebookFile?.isEBookFile) {
+ Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
+ return res.status(400).send('Invalid ebook file id')
+ }
+ } else {
+ ebookFile = req.libraryItem.media.ebookFile
+ }
+
+ if (!ebookFile) {
+ Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.title}"`)
+ return res.sendStatus(404)
+ }
+
+ const ext = (ebookFile.metadata?.ext || '').toLowerCase()
+ if (ext !== '.cbz' && ext !== '.cbr') {
+ Logger.error(`[LibraryItemController] File is not a comic book: ${ext}`)
+ return res.status(400).send('File is not a comic book (cbz/cbr)')
+ }
+
+ try {
+ const comicPath = ebookFile.metadata.path
+ const fileIno = ebookFile.ino
+ const { pages, numPages } = await ComicCacheManager.getComicMetadata(
+ req.libraryItem.id,
+ fileIno,
+ comicPath
+ )
+
+ res.json({
+ libraryItemId: req.libraryItem.id,
+ fileIno,
+ numPages,
+ pages: pages.map((p, i) => ({
+ page: i + 1,
+ filename: p
+ }))
+ })
+ } catch (error) {
+ Logger.error(`[LibraryItemController] Failed to get comic pages: ${error.message}`)
+ res.status(500).send('Failed to read comic file')
+ }
+ }
+
+ /**
+ * GET api/items/:id/comic-page/:page/:fileid?
+ * Get a single comic page (extracted and cached on server)
+ * Public endpoint (no auth required, like covers)
+ *
+ * @param {Request} req
+ * @param {Response} res
+ */
+ async getComicPage(req, res) {
+ const ComicCacheManager = require('../managers/ComicCacheManager')
+
+ const libraryItemId = req.params.id
+ if (!libraryItemId) {
+ return res.sendStatus(400)
+ }
+
+ const pageNum = parseInt(req.params.page, 10)
+ if (isNaN(pageNum) || pageNum < 1) {
+ return res.status(400).send('Invalid page number')
+ }
+
+ // Fetch library item directly (no auth middleware)
+ const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
+ if (!libraryItem?.media) {
+ return res.sendStatus(404)
+ }
+
+ let ebookFile = null
+ if (req.params.fileid) {
+ ebookFile = libraryItem.getLibraryFileWithIno(req.params.fileid)
+ if (!ebookFile?.isEBookFile) {
+ Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
+ return res.status(400).send('Invalid ebook file id')
+ }
+ } else {
+ ebookFile = libraryItem.media.ebookFile
+ }
+
+ if (!ebookFile) {
+ Logger.error(`[LibraryItemController] No ebookFile for library item "${libraryItem.media.title}"`)
+ return res.sendStatus(404)
+ }
+
+ const ext = (ebookFile.metadata?.ext || '').toLowerCase()
+ if (ext !== '.cbz' && ext !== '.cbr') {
+ Logger.error(`[LibraryItemController] File is not a comic book: ${ext}`)
+ return res.status(400).send('File is not a comic book (cbz/cbr)')
+ }
+
+ try {
+ const comicPath = ebookFile.metadata.path
+ const fileIno = ebookFile.ino
+ const result = await ComicCacheManager.getPage(
+ libraryItemId,
+ fileIno,
+ comicPath,
+ pageNum
+ )
+
+ if (!result) {
+ return res.sendStatus(404)
+ }
+
+ // Set cache headers for browser caching
+ res.set({
+ 'Content-Type': result.contentType,
+ 'Cache-Control': 'private, max-age=86400' // Cache for 24 hours
+ })
+
+ if (global.XAccel) {
+ const encodedURI = encodeUriPath(global.XAccel + result.path)
+ Logger.debug(`Use X-Accel to serve comic page ${encodedURI}`)
+ return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
+ }
+
+ res.sendFile(result.path)
+ } catch (error) {
+ Logger.error(`[LibraryItemController] Failed to get comic page: ${error.message}`)
+ res.status(500).send('Failed to extract comic page')
+ }
+ }
+
/**
*
* @param {RequestWithUser} req
diff --git a/server/managers/ComicCacheManager.js b/server/managers/ComicCacheManager.js
new file mode 100644
index 000000000..db57d6ed9
--- /dev/null
+++ b/server/managers/ComicCacheManager.js
@@ -0,0 +1,301 @@
+const Path = require('path')
+const fs = require('../libs/fsExtra')
+const Logger = require('../Logger')
+const { createComicBookExtractor } = require('../utils/comicBookExtractors')
+
+/**
+ * Manages caching of extracted comic book pages for performance.
+ * Pages are extracted on-demand and cached to disk.
+ */
+class ComicCacheManager {
+ constructor() {
+ this.ComicCachePath = null
+ // In-memory cache of comic metadata (page lists)
+ // Key: libraryItemId:fileIno, Value: { pages: string[], mtime: number }
+ this.metadataCache = new Map()
+ // Track open extractors for reuse within a session
+ this.extractorCache = new Map()
+ this.extractorTimeout = 30000 // Close extractors after 30s of inactivity
+ }
+
+ /**
+ * Initialize cache directory
+ */
+ async ensureCachePaths() {
+ this.ComicCachePath = Path.join(global.MetadataPath, 'cache', 'comics')
+ try {
+ await fs.ensureDir(this.ComicCachePath)
+ } catch (error) {
+ Logger.error(`[ComicCacheManager] Failed to create cache directory at "${this.ComicCachePath}": ${error.message}`)
+ throw error
+ }
+ }
+
+ /**
+ * Get cache directory for a specific comic
+ * @param {string} libraryItemId
+ * @param {string} fileIno
+ * @returns {string}
+ */
+ getComicCacheDir(libraryItemId, fileIno) {
+ return Path.join(this.ComicCachePath, `${libraryItemId}_${fileIno}`)
+ }
+
+ /**
+ * Get cached page path
+ * @param {string} libraryItemId
+ * @param {string} fileIno
+ * @param {number} pageNum
+ * @param {string} ext
+ * @returns {string}
+ */
+ getCachedPagePath(libraryItemId, fileIno, pageNum, ext) {
+ const cacheDir = this.getComicCacheDir(libraryItemId, fileIno)
+ return Path.join(cacheDir, `page_${String(pageNum).padStart(5, '0')}${ext}`)
+ }
+
+ /**
+ * Parse image filenames and return sorted page list
+ * @param {string[]} filenames
+ * @returns {string[]}
+ */
+ parseAndSortPages(filenames) {
+ const acceptableImages = ['.jpeg', '.jpg', '.png', '.webp', '.gif']
+
+ const imageFiles = filenames.filter(f => {
+ const ext = (Path.extname(f) || '').toLowerCase()
+ return acceptableImages.includes(ext)
+ })
+
+ // Sort by numeric value in filename
+ const parsed = imageFiles.map(filename => {
+ const basename = Path.basename(filename, Path.extname(filename))
+ const numbers = basename.match(/\d+/g)
+ return {
+ filename,
+ index: numbers?.length ? Number(numbers[numbers.length - 1]) : -1
+ }
+ })
+
+ const withNum = parsed.filter(p => p.index >= 0).sort((a, b) => a.index - b.index)
+ const withoutNum = parsed.filter(p => p.index < 0)
+
+ return [...withNum, ...withoutNum].map(p => p.filename)
+ }
+
+ /**
+ * Get or create an extractor for a comic, with caching
+ * @param {string} comicPath
+ * @param {string} cacheKey
+ * @returns {Promise