import fs from 'fs' import path from 'path' import AdmZip from 'adm-zip' import type { ComicIssue, ComicSeries } from '@/types' import { getDb } from './db' import { HIDDEN_FILES, thumbnailApiUrl } from './media-utils' import { countZipImages, mapConcurrent } from './zip-utils' import fsPromises from 'fs/promises' const CBZ_EXTENSIONS = new Set(['.cbz']) const CBZ_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']) function isCbzFile(name: string): boolean { return CBZ_EXTENSIONS.has(path.extname(name).toLowerCase()) } function naturalCompare(a: string, b: string): number { return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) } function parseIssueNumber(filename: string): number | null { const base = path.basename(filename, path.extname(filename)) const matches = base.match(/\d+/g) if (!matches) return null return parseInt(matches[matches.length - 1], 10) } export interface ScannedComicSeries extends ComicSeries { issues: ComicIssue[] } const TRASH_DIR = '.trash' async function moveToTrash(absPath: string, libraryRoot: string): Promise { const trashDir = path.join(libraryRoot, TRASH_DIR) await fsPromises.mkdir(trashDir, { recursive: true }) const filename = path.basename(absPath) let dest = path.join(trashDir, filename) if (fs.existsSync(dest)) { const ext = path.extname(filename) const base = path.basename(filename, ext) dest = path.join(trashDir, `${base}_${Date.now()}${ext}`) } await fsPromises.rename(absPath, dest).catch(async (err: NodeJS.ErrnoException) => { if (err.code === 'EXDEV') { // Source and destination are on different filesystems — copy then delete. await fsPromises.copyFile(absPath, dest) await fsPromises.unlink(absPath) } else { throw err } }) console.log(`[scanner] Moved corrupt archive to trash: ${path.relative(libraryRoot, absPath)}`) } interface CollectedCbz { absPath: string filename: string relPath: string isStandalone: boolean seriesDirName: string | null } export async function scanComicsLibrary( libraryRoot: string, libraryId: string ): Promise<(ComicIssue | ScannedComicSeries)[]> { let topEntries: fs.Dirent[] try { topEntries = fs.readdirSync(libraryRoot, { withFileTypes: true }) } catch { return [] } // Phase 1: Collect all CBZ paths via fast directory listing (no archive opens). const collected: CollectedCbz[] = [] for (const entry of topEntries) { if (HIDDEN_FILES.test(entry.name)) continue if (entry.isFile() && isCbzFile(entry.name)) { collected.push({ absPath: path.join(libraryRoot, entry.name), filename: entry.name, relPath: entry.name, isStandalone: true, seriesDirName: null, }) continue } if (entry.isDirectory()) { const dirAbsPath = path.join(libraryRoot, entry.name) let subEntries: fs.Dirent[] try { subEntries = fs.readdirSync(dirAbsPath, { withFileTypes: true }) } catch { continue } const cbzFiles = subEntries .filter((e) => e.isFile() && isCbzFile(e.name) && !HIDDEN_FILES.test(e.name)) .sort((a, b) => naturalCompare(a.name, b.name)) if (cbzFiles.length === 0) continue for (const f of cbzFiles) { collected.push({ absPath: path.join(dirAbsPath, f.name), filename: f.name, relPath: path.join(entry.name, f.name), isStandalone: false, seriesDirName: entry.name, }) } } } // Phase 2: Count pages for all CBZ files concurrently (10 at a time) by reading // only each archive's central directory — no full-file reads. const scanResults = await mapConcurrent(collected, 10, (c) => countZipImages(c.absPath, CBZ_IMAGE_EXTENSIONS) ) // Move corrupt archives to the library's .trash folder and exclude them from indexing. const movePromises: Promise[] = [] const valid: Array<{ cbz: CollectedCbz; pageCount: number }> = [] for (let i = 0; i < collected.length; i++) { const result = scanResults[i] if (!result.valid) { movePromises.push( moveToTrash(collected[i].absPath, libraryRoot).catch((err) => console.warn(`[scanner] Could not move corrupt archive to trash: ${collected[i].absPath}`, err) ) ) continue } valid.push({ cbz: collected[i], pageCount: result.pageCount }) } if (movePromises.length > 0) await Promise.all(movePromises) // Phase 3: Build the result array from valid files only. const seriesMap = new Map() const standaloneIssues: ComicIssue[] = [] for (const { cbz: c, pageCount } of valid) { const coverUrl = thumbnailApiUrl(libraryId, c.relPath) const issue: ComicIssue = { id: encodeURIComponent(c.relPath), title: path.basename(c.filename, path.extname(c.filename)), issueNumber: parseIssueNumber(c.filename), pageCount, coverUrl, filePath: c.relPath, isStandalone: c.isStandalone, userRating: null, aiDescription: null, extractedText: null, extractedTextTranslated: null, } if (c.isStandalone) { standaloneIssues.push(issue) } else { const key = c.seriesDirName! if (!seriesMap.has(key)) { seriesMap.set(key, { id: encodeURIComponent(key), title: key, coverUrl, // first issue (sorted) becomes the series cover issueCount: 0, issues: [], userRating: null, aiDescription: null, extractedText: null, extractedTextTranslated: null, }) } const series = seriesMap.get(key)! series.issues.push(issue) series.issueCount++ } } const results: (ComicIssue | ScannedComicSeries)[] = [ ...Array.from(seriesMap.values()), ...standaloneIssues, ] return results.sort((a, b) => naturalCompare(a.title, b.title)) } function escapeLike(s: string): string { return `%${s.replace(/%/g, '\\%').replace(/_/g, '\\_')}%` } // comicsFromDb returns series + standalone issues for the top-level grid, paginated. // Series issues are retrieved separately via comicIssuesFromDb. export function comicsFromDb( libraryId: string, opts: { page: number; pageSize: number; search?: string } ): { items: (ComicIssue | ComicSeries)[]; total: number } { const db = getDb() const offset = (opts.page - 1) * opts.pageSize type DbRow = { item_key: string item_type: string parent_key: string | null title: string | null metadata: string | null file_path: string | null user_rating: number | null ai_description: string | null extracted_text: string | null extracted_text_translated: string | null } const baseWhere = ` WHERE library_id = ? AND (item_type = 'comic_series' OR (item_type = 'comic_issue' AND parent_key IS NULL)) ` const total: number = opts.search ? (db .prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'`) .get(libraryId, escapeLike(opts.search)) as { cnt: number }).cnt : (db .prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere}`) .get(libraryId) as { cnt: number }).cnt const cols = `item_key, item_type, parent_key, title, metadata, file_path, user_rating, ai_description, extracted_text, extracted_text_translated` const rows: DbRow[] = opts.search ? db .prepare( `SELECT ${cols} FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\' ORDER BY title LIMIT ? OFFSET ?` ) .all(libraryId, escapeLike(opts.search), opts.pageSize, offset) as DbRow[] : db .prepare( `SELECT ${cols} FROM media_items ${baseWhere} ORDER BY title LIMIT ? OFFSET ?` ) .all(libraryId, opts.pageSize, offset) as DbRow[] const items: (ComicIssue | ComicSeries)[] = [] for (const row of rows) { const meta = row.metadata ? JSON.parse(row.metadata) : {} if (row.item_type === 'comic_series') { const idPart = row.item_key.split(':comic_series:')[1] ?? row.item_key items.push({ id: idPart, item_key: row.item_key, title: row.title ?? decodeURIComponent(idPart), coverUrl: meta.coverUrl ?? null, issueCount: meta.issueCount ?? 0, userRating: row.user_rating ?? null, aiDescription: row.ai_description ?? null, extractedText: row.extracted_text ?? null, extractedTextTranslated: row.extracted_text_translated ?? null, } as ComicSeries) } else { const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key items.push({ id: idPart, item_key: row.item_key, title: row.title ?? decodeURIComponent(idPart.split(':').pop() ?? idPart), issueNumber: meta.issueNumber ?? null, pageCount: meta.pageCount ?? 0, coverUrl: meta.coverUrl ?? null, filePath: row.file_path ?? '', isStandalone: meta.isStandalone ?? true, userRating: row.user_rating ?? null, aiDescription: row.ai_description ?? null, extractedText: row.extracted_text ?? null, extractedTextTranslated: row.extracted_text_translated ?? null, } as ComicIssue) } } return { items, total } } export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIssue[] { const db = getDb() const seriesKey = `${libraryId}:comic_series:${seriesId}` type DbRow = { item_key: string title: string | null metadata: string | null file_path: string | null user_rating: number | null ai_description: string | null extracted_text: string | null extracted_text_translated: string | null } const rows = db .prepare( `SELECT item_key, title, metadata, file_path, user_rating, ai_description, extracted_text, extracted_text_translated FROM media_items WHERE parent_key = ? AND item_type = 'comic_issue'` ) .all(seriesKey) as DbRow[] const issues: ComicIssue[] = rows.map((row) => { const meta = row.metadata ? JSON.parse(row.metadata) : {} const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key return { id: idPart, item_key: row.item_key, title: row.title ?? decodeURIComponent(idPart.split(':').pop() ?? idPart), issueNumber: meta.issueNumber ?? null, pageCount: meta.pageCount ?? 0, coverUrl: meta.coverUrl ?? null, filePath: row.file_path ?? '', isStandalone: false, userRating: row.user_rating ?? null, aiDescription: row.ai_description ?? null, extractedText: row.extracted_text ?? null, extractedTextTranslated: row.extracted_text_translated ?? null, } }) return issues.sort((a, b) => { if (a.issueNumber !== null && b.issueNumber !== null) return a.issueNumber - b.issueNumber if (a.issueNumber !== null) return -1 if (b.issueNumber !== null) return 1 return naturalCompare(a.title, b.title) }) } export function getComicPages(absoluteCbzPath: string): string[] { try { const zip = new AdmZip(absoluteCbzPath) return zip .getEntries() .filter( (e) => !e.isDirectory && CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase()) ) .sort((a, b) => naturalCompare(a.entryName, b.entryName)) .map((e) => e.entryName) } catch { return [] } } export function getComicPageBuffer(absoluteCbzPath: string, pageIndex: number): { buffer: Buffer; ext: string } | null { try { const zip = new AdmZip(absoluteCbzPath) const entries = zip .getEntries() .filter( (e) => !e.isDirectory && CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase()) ) .sort((a, b) => naturalCompare(a.entryName, b.entryName)) if (pageIndex < 0 || pageIndex >= entries.length) return null const entry = entries[pageIndex] const buffer = entry.getData() const ext = path.extname(entry.entryName).toLowerCase() return { buffer, ext } } catch { return null } }