add manga library

This commit is contained in:
Garret Patti
2026-04-19 20:25:06 -04:00
parent fbcd592609
commit b0e9c9790c
19 changed files with 1654 additions and 52 deletions

278
src/lib/comics.ts Normal file
View File

@@ -0,0 +1,278 @@
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<string, ComicSeries>()
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
}
}