This repository has been archived on 2026-06-15. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MediaLore/src/lib/comics.ts
2026-04-21 10:57:08 -04:00

378 lines
12 KiB
TypeScript

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<void> {
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<void>[] = []
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<string, ScannedComicSeries>()
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
}
}