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-20 08:28:43 -04:00

291 lines
8.4 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'
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))
}
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
}
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 rows: DbRow[] = opts.search
? db
.prepare(
`SELECT item_key, item_type, parent_key, title, metadata, file_path
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 item_key, item_type, parent_key, title, metadata, file_path
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,
} 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,
} 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
}
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
}
}