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' 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) } function getPageCount(absoluteCbzPath: string): number { try { const zip = new AdmZip(absoluteCbzPath) return zip .getEntries() .filter( (e) => !e.isDirectory && CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase()) ).length } catch { return 0 } } function buildIssue( absFilePath: string, filename: string, filePath: string, libraryId: string, isStandalone: boolean ): ComicIssue { const title = path.basename(filename, path.extname(filename)) const issueNumber = parseIssueNumber(filename) const pageCount = getPageCount(absFilePath) const coverUrl = thumbnailApiUrl(libraryId, filePath) return { id: encodeURIComponent(filePath), title, issueNumber, pageCount, coverUrl, filePath, isStandalone, } } export interface ScannedComicSeries extends ComicSeries { issues: ComicIssue[] } export function scanComicsLibrary( libraryRoot: string, libraryId: string ): (ComicIssue | ScannedComicSeries)[] { let topEntries: fs.Dirent[] try { topEntries = fs.readdirSync(libraryRoot, { withFileTypes: true }) } catch { return [] } const results: (ComicIssue | ScannedComicSeries)[] = [] for (const entry of topEntries) { if (HIDDEN_FILES.test(entry.name)) continue if (entry.isFile() && isCbzFile(entry.name)) { // Standalone one-shot comic const absPath = path.join(libraryRoot, entry.name) results.push(buildIssue(absPath, entry.name, entry.name, libraryId, true)) 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) ) if (cbzFiles.length === 0) continue // It's a series const issues: ComicIssue[] = cbzFiles .sort((a, b) => naturalCompare(a.name, b.name)) .map((f) => { const relPath = path.join(entry.name, f.name) return buildIssue(path.join(dirAbsPath, f.name), f.name, relPath, libraryId, false) }) const seriesCoverUrl = issues[0]?.coverUrl ?? null results.push({ id: encodeURIComponent(entry.name), title: entry.name, coverUrl: seriesCoverUrl, issueCount: issues.length, issues, }) } } return results.sort((a, b) => naturalCompare(a.title, b.title)) } // comicsFromDb returns series + standalone issues for the top-level grid. // Series issues are retrieved separately via comicIssuesFromDb. export function comicsFromDb(libraryId: string): (ComicIssue | ComicSeries)[] { const db = getDb() type DbRow = { item_key: string item_type: string parent_key: string | null title: string | null metadata: string | null file_path: string | null } const allRows = db .prepare( `SELECT item_key, item_type, parent_key, title, metadata, file_path FROM media_items WHERE library_id = ? AND item_type IN ('comic_series','comic_issue') ORDER BY title` ) .all(libraryId) as DbRow[] const seriesMap = new Map() const standaloneIssues: ComicIssue[] = [] for (const row of allRows) { if (row.item_type !== 'comic_series') continue const meta = row.metadata ? JSON.parse(row.metadata) : {} const idPart = row.item_key.split(':comic_series:')[1] ?? row.item_key seriesMap.set(row.item_key, { id: idPart, item_key: row.item_key, title: row.title ?? decodeURIComponent(idPart), coverUrl: meta.coverUrl ?? null, issueCount: meta.issueCount ?? 0, }) } for (const row of allRows) { if (row.item_type !== 'comic_issue') continue const meta = row.metadata ? JSON.parse(row.metadata) : {} const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key const issue: ComicIssue = { 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 ?? false, } if (row.parent_key && seriesMap.has(row.parent_key)) { // Series issues are not included in the top-level grid — series card represents them // We only include series cards + standalone issues in the grid } else { standaloneIssues.push(issue) } } const results: (ComicIssue | ComicSeries)[] = [ ...Array.from(seriesMap.values()), ...standaloneIssues, ] return results.sort((a, b) => naturalCompare(a.title, b.title)) } 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 } const rows = db .prepare( `SELECT item_key, title, metadata, file_path 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, } }) 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 } }