378 lines
12 KiB
TypeScript
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
|
|
}
|
|
}
|