279 lines
7.9 KiB
TypeScript
279 lines
7.9 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))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|