From 2245d7e9c66af2b5a1cfd0134cbde50d64b1977c Mon Sep 17 00:00:00 2001 From: clawdbot Date: Sat, 21 Feb 2026 11:28:30 -0500 Subject: [PATCH 1/3] fix: server-side comic page extraction with caching Fixes #3505 - Large comic books (300+ MB) were unusable due to client-side extraction. Changes: - Add ComicCacheManager for server-side page extraction and caching - Add GET /api/items/:id/comic-pages endpoint for page metadata - Add GET /api/items/:id/comic-page/:page endpoint for individual pages - Update ComicReader.vue to fetch pages on-demand from server - Add browser-side preloading for adjacent pages Before: Client downloads entire comic file, extracts in browser After: Server extracts pages on-demand, caches to disk, streams to client Performance improvements: - Initial load: Only metadata request (~1KB) instead of full file (300MB+) - Page turns: Single image request (~100KB-2MB) with disk caching - Memory: No longer loads entire archive in browser memory - Subsequent views: Cached pages served instantly from disk --- client/components/readers/ComicReader.vue | 256 ++++++++--------- server/Server.js | 2 + server/controllers/LibraryItemController.js | 127 +++++++++ server/managers/ComicCacheManager.js | 301 ++++++++++++++++++++ server/routers/ApiRouter.js | 3 + 5 files changed, 545 insertions(+), 144 deletions(-) create mode 100644 server/managers/ComicCacheManager.js 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/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..1729c1d5c 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -1157,6 +1157,133 @@ 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) + * + * @param {LibraryItemControllerRequest} req + * @param {Response} res + */ + async getComicPage(req, res) { + const ComicCacheManager = require('../managers/ComicCacheManager') + + const pageNum = parseInt(req.params.page, 10) + if (isNaN(pageNum) || pageNum < 1) { + return res.status(400).send('Invalid page number') + } + + 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 result = await ComicCacheManager.getPage( + req.libraryItem.id, + 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} + */ + async getExtractor(comicPath, cacheKey) { + const cached = this.extractorCache.get(cacheKey) + if (cached) { + clearTimeout(cached.timeout) + cached.timeout = setTimeout(() => this.closeExtractor(cacheKey), this.extractorTimeout) + return cached.extractor + } + + const extractor = createComicBookExtractor(comicPath) + await extractor.open() + + const timeout = setTimeout(() => this.closeExtractor(cacheKey), this.extractorTimeout) + this.extractorCache.set(cacheKey, { extractor, timeout }) + + return extractor + } + + /** + * Close and remove a cached extractor + * @param {string} cacheKey + */ + closeExtractor(cacheKey) { + const cached = this.extractorCache.get(cacheKey) + if (cached) { + clearTimeout(cached.timeout) + try { + cached.extractor.close() + } catch (e) { + Logger.debug(`[ComicCacheManager] Error closing extractor: ${e.message}`) + } + this.extractorCache.delete(cacheKey) + Logger.debug(`[ComicCacheManager] Closed extractor for ${cacheKey}`) + } + } + + /** + * Get comic metadata (page list) with caching + * @param {string} libraryItemId + * @param {string} fileIno + * @param {string} comicPath + * @returns {Promise<{pages: string[], numPages: number}>} + */ + async getComicMetadata(libraryItemId, fileIno, comicPath) { + const cacheKey = `${libraryItemId}:${fileIno}` + + // Check memory cache + const cached = this.metadataCache.get(cacheKey) + if (cached) { + // Verify file hasn't changed + try { + const stat = await fs.stat(comicPath) + if (stat.mtimeMs === cached.mtime) { + return { pages: cached.pages, numPages: cached.pages.length } + } + } catch (e) { + // File may have been removed + } + this.metadataCache.delete(cacheKey) + } + + // Extract metadata + const extractor = await this.getExtractor(comicPath, cacheKey) + const allFiles = await extractor.getFilePaths() + const pages = this.parseAndSortPages(allFiles) + + // Get file mtime for cache validation + const stat = await fs.stat(comicPath) + + // Cache in memory + this.metadataCache.set(cacheKey, { + pages, + mtime: stat.mtimeMs + }) + + Logger.debug(`[ComicCacheManager] Cached metadata for ${cacheKey}: ${pages.length} pages`) + + return { pages, numPages: pages.length } + } + + /** + * Get a specific page, extracting and caching if necessary + * @param {string} libraryItemId + * @param {string} fileIno + * @param {string} comicPath + * @param {number} pageNum - 1-indexed page number + * @returns {Promise<{path: string, contentType: string} | null>} + */ + async getPage(libraryItemId, fileIno, comicPath, pageNum) { + const cacheKey = `${libraryItemId}:${fileIno}` + + // Get page list + const { pages } = await this.getComicMetadata(libraryItemId, fileIno, comicPath) + + if (pageNum < 1 || pageNum > pages.length) { + Logger.error(`[ComicCacheManager] Invalid page number ${pageNum} for comic with ${pages.length} pages`) + return null + } + + const pageFilename = pages[pageNum - 1] + const ext = Path.extname(pageFilename).toLowerCase() + const cachedPath = this.getCachedPagePath(libraryItemId, fileIno, pageNum, ext) + + // Check if already cached + if (await fs.pathExists(cachedPath)) { + Logger.debug(`[ComicCacheManager] Serving cached page ${pageNum} from ${cachedPath}`) + return { + path: cachedPath, + contentType: this.getContentType(ext) + } + } + + // Extract and cache the page + const cacheDir = this.getComicCacheDir(libraryItemId, fileIno) + await fs.ensureDir(cacheDir) + + const extractor = await this.getExtractor(comicPath, cacheKey) + const success = await extractor.extractToFile(pageFilename, cachedPath) + + if (!success) { + Logger.error(`[ComicCacheManager] Failed to extract page ${pageNum} (${pageFilename})`) + return null + } + + Logger.debug(`[ComicCacheManager] Extracted and cached page ${pageNum} to ${cachedPath}`) + + return { + path: cachedPath, + contentType: this.getContentType(ext) + } + } + + /** + * Get content type for image extension + * @param {string} ext + * @returns {string} + */ + getContentType(ext) { + const types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.gif': 'image/gif' + } + return types[ext] || 'application/octet-stream' + } + + /** + * Purge cached pages for a specific comic + * @param {string} libraryItemId + * @param {string} fileIno + */ + async purgeComicCache(libraryItemId, fileIno) { + const cacheKey = `${libraryItemId}:${fileIno}` + const cacheDir = this.getComicCacheDir(libraryItemId, fileIno) + + // Close any open extractor + this.closeExtractor(cacheKey) + + // Remove metadata cache + this.metadataCache.delete(cacheKey) + + // Remove disk cache + if (await fs.pathExists(cacheDir)) { + await fs.remove(cacheDir) + Logger.info(`[ComicCacheManager] Purged cache for ${cacheKey}`) + } + } + + /** + * Purge all cached pages for a library item + * @param {string} libraryItemId + */ + async purgeLibraryItemCache(libraryItemId) { + // Close any open extractors for this item + for (const [key] of this.extractorCache) { + if (key.startsWith(`${libraryItemId}:`)) { + this.closeExtractor(key) + } + } + + // Remove metadata cache entries + for (const [key] of this.metadataCache) { + if (key.startsWith(`${libraryItemId}:`)) { + this.metadataCache.delete(key) + } + } + + // Remove disk cache + const files = await fs.readdir(this.ComicCachePath).catch(() => []) + for (const file of files) { + if (file.startsWith(`${libraryItemId}_`)) { + await fs.remove(Path.join(this.ComicCachePath, file)).catch(() => {}) + } + } + + Logger.info(`[ComicCacheManager] Purged all cache for library item ${libraryItemId}`) + } + + /** + * Close all open extractors (for shutdown) + */ + closeAllExtractors() { + for (const [key] of this.extractorCache) { + this.closeExtractor(key) + } + } +} + +module.exports = new ComicCacheManager() diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5ec..3bc989543 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -126,6 +126,9 @@ class ApiRouter { this.router.get('/items/:id/file/:fileid/download', LibraryItemController.middleware.bind(this), LibraryItemController.downloadLibraryFile.bind(this)) this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this)) this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this)) + // Comic page routes - server-side extraction with caching for performance + this.router.get('/items/:id/comic-pages/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getComicPages.bind(this)) + this.router.get('/items/:id/comic-page/:page/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getComicPage.bind(this)) // // User Routes From c6c8b378ab42fb1c78c8c359b422ffaaef230eaa Mon Sep 17 00:00:00 2001 From: clawdbot Date: Sat, 21 Feb 2026 12:07:53 -0500 Subject: [PATCH 2/3] fix: make comic-page endpoint public (like covers) The img src tag doesn't send auth headers, so the comic-page endpoint needs to be publicly accessible like the cover endpoint. --- server/controllers/LibraryItemController.js | 22 ++++++++++++++++----- server/routers/ApiRouter.js | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 1729c1d5c..a139aa36e 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -1217,31 +1217,43 @@ class LibraryItemController { /** * 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 {LibraryItemControllerRequest} req + * @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 = req.libraryItem.getLibraryFileWithIno(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 = req.libraryItem.media.ebookFile + ebookFile = libraryItem.media.ebookFile } if (!ebookFile) { - Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.title}"`) + Logger.error(`[LibraryItemController] No ebookFile for library item "${libraryItem.media.title}"`) return res.sendStatus(404) } @@ -1255,7 +1267,7 @@ class LibraryItemController { const comicPath = ebookFile.metadata.path const fileIno = ebookFile.ino const result = await ComicCacheManager.getPage( - req.libraryItem.id, + libraryItemId, fileIno, comicPath, pageNum diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 3bc989543..cfbcdec70 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -128,7 +128,7 @@ class ApiRouter { this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this)) // Comic page routes - server-side extraction with caching for performance this.router.get('/items/:id/comic-pages/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getComicPages.bind(this)) - this.router.get('/items/:id/comic-page/:page/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getComicPage.bind(this)) + this.router.get('/items/:id/comic-page/:page/:fileid?', LibraryItemController.getComicPage.bind(this)) // // User Routes From c7d4a0cba89eff2de7b1613de3835fc379b4af80 Mon Sep 17 00:00:00 2001 From: clawdbot Date: Sat, 21 Feb 2026 12:10:55 -0500 Subject: [PATCH 3/3] fix: add comic-page to auth bypass patterns Like covers and author images, comic pages need to be accessible without authentication for img src to work. --- server/Auth.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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()