add manga library
This commit is contained in:
278
src/lib/comics.ts
Normal file
278
src/lib/comics.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,8 @@ function initDb(db: Database.Database): void {
|
||||
migrateLibraryAiSettings(db)
|
||||
migrateAiJobs(db)
|
||||
migrateLibraryPermissionsAccessLevel(db)
|
||||
migrateLibrariesAddComics(db)
|
||||
migrateComicItemTypes(db)
|
||||
seedAppSettings(db)
|
||||
}
|
||||
|
||||
@@ -319,6 +321,68 @@ function migrateLibrariesType(db: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLibrariesAddComics(db: Database.Database): void {
|
||||
const row = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
||||
.get() as { sql: string } | undefined
|
||||
if (!row || row.sql.includes("'comics'")) return
|
||||
|
||||
db.exec(`
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE libraries_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('comics','games','mixed','movies','tv')),
|
||||
cover_ext TEXT NULL
|
||||
);
|
||||
INSERT INTO libraries_new SELECT * FROM libraries;
|
||||
DROP TABLE libraries;
|
||||
ALTER TABLE libraries_new RENAME TO libraries;
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
|
||||
function migrateComicItemTypes(db: Database.Database): void {
|
||||
const row = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_items'")
|
||||
.get() as { sql: string } | undefined
|
||||
if (!row || row.sql.includes("'comic_series'")) return
|
||||
|
||||
db.exec(`
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE media_items_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
item_key TEXT NOT NULL UNIQUE,
|
||||
item_type TEXT NOT NULL CHECK(item_type IN (
|
||||
'movie','tv_series','tv_season','tv_episode',
|
||||
'game','game_series','mixed_file',
|
||||
'comic_series','comic_issue')),
|
||||
parent_key TEXT,
|
||||
title TEXT,
|
||||
year INTEGER,
|
||||
plot TEXT,
|
||||
genres TEXT,
|
||||
metadata TEXT,
|
||||
file_path TEXT,
|
||||
fingerprint TEXT,
|
||||
scanned_at INTEGER NOT NULL,
|
||||
ai_tagged_at INTEGER,
|
||||
ai_description TEXT,
|
||||
extracted_text TEXT,
|
||||
extracted_text_translated TEXT
|
||||
);
|
||||
INSERT INTO media_items_new SELECT * FROM media_items;
|
||||
DROP TABLE media_items;
|
||||
ALTER TABLE media_items_new RENAME TO media_items;
|
||||
CREATE INDEX media_items_library_id ON media_items(library_id);
|
||||
CREATE INDEX media_items_parent_key ON media_items(parent_key);
|
||||
CREATE INDEX media_items_fingerprint ON media_items(fingerprint);
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
|
||||
function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
|
||||
const row = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import path from 'path'
|
||||
import type Database from 'better-sqlite3'
|
||||
import type { Library, Movie, TvSeries, TvSeason, TvEpisode, Game, GameSeries } from '@/types'
|
||||
import type { Library, Movie, TvSeries, TvSeason, TvEpisode, Game, GameSeries, ComicIssue } from '@/types'
|
||||
import { getDb } from './db'
|
||||
import { getLibraries, resolveLibraryRoot } from './libraries'
|
||||
import { setScanLastRan } from './app-settings'
|
||||
import { scanMoviesLibrary } from './movies'
|
||||
import { scanTvLibrary, scanTvSeasons, scanTvEpisodes } from './tv'
|
||||
import { scanGamesLibrary } from './games'
|
||||
import { getThumbnailPath } from './thumbnails'
|
||||
import { scanComicsLibrary, type ScannedComicSeries } from './comics'
|
||||
import { getThumbnailPath, getCbzThumbnailPath } from './thumbnails'
|
||||
import { computeFingerprint } from './fingerprint'
|
||||
import { reKeyMediaItem } from './tags'
|
||||
import { runAiTagging } from './ai-tagger'
|
||||
@@ -70,6 +71,9 @@ export async function runLibraryScan(library: Library): Promise<void> {
|
||||
case 'mixed':
|
||||
await scanMixed(library, libraryRoot)
|
||||
break
|
||||
case 'comics':
|
||||
await scanComics(library, libraryRoot)
|
||||
break
|
||||
}
|
||||
|
||||
await runAiTagging(library, libraryRoot).catch((err) =>
|
||||
@@ -536,6 +540,119 @@ async function scanMixed(library: Library, libraryRoot: string): Promise<void> {
|
||||
console.log(`[scanner] mixed: indexed ${newItems.size} files`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comics (clear+upsert pattern — CBZ files are immutable archives)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function scanComics(library: Library, libraryRoot: string): Promise<void> {
|
||||
const items = scanComicsLibrary(libraryRoot, library.id)
|
||||
const db = getDb()
|
||||
const now = Date.now()
|
||||
|
||||
clearLibraryItems(db, library.id)
|
||||
|
||||
const upsertSeries = db.prepare(`
|
||||
INSERT INTO media_items (library_id, item_key, item_type, title, metadata, file_path, scanned_at)
|
||||
VALUES (@library_id, @item_key, @item_type, @title, @metadata, @file_path, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
metadata = excluded.metadata,
|
||||
file_path = excluded.file_path,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
const upsertIssue = db.prepare(`
|
||||
INSERT INTO media_items (library_id, item_key, item_type, parent_key, title, metadata, file_path, scanned_at)
|
||||
VALUES (@library_id, @item_key, @item_type, @parent_key, @title, @metadata, @file_path, @scanned_at)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
parent_key = excluded.parent_key,
|
||||
title = excluded.title,
|
||||
metadata = excluded.metadata,
|
||||
file_path = excluded.file_path,
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
let issueCount = 0
|
||||
|
||||
db.transaction(() => {
|
||||
for (const item of items) {
|
||||
if ('issues' in item) {
|
||||
const series = item as ScannedComicSeries
|
||||
const seriesKey = `${library.id}:comic_series:${series.id}`
|
||||
upsertSeries.run({
|
||||
library_id: library.id,
|
||||
item_key: seriesKey,
|
||||
item_type: 'comic_series',
|
||||
title: series.title,
|
||||
metadata: JSON.stringify({
|
||||
issueCount: series.issueCount,
|
||||
coverUrl: series.coverUrl,
|
||||
}),
|
||||
file_path: null,
|
||||
scanned_at: now,
|
||||
})
|
||||
|
||||
for (const issue of series.issues) {
|
||||
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
||||
upsertIssue.run({
|
||||
library_id: library.id,
|
||||
item_key: issueKey,
|
||||
item_type: 'comic_issue',
|
||||
parent_key: seriesKey,
|
||||
title: issue.title,
|
||||
metadata: JSON.stringify({
|
||||
issueNumber: issue.issueNumber,
|
||||
pageCount: issue.pageCount,
|
||||
coverUrl: issue.coverUrl,
|
||||
isStandalone: false,
|
||||
}),
|
||||
file_path: issue.filePath,
|
||||
scanned_at: now,
|
||||
})
|
||||
issueCount++
|
||||
}
|
||||
} else {
|
||||
const issue = item as ComicIssue
|
||||
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
||||
upsertIssue.run({
|
||||
library_id: library.id,
|
||||
item_key: issueKey,
|
||||
item_type: 'comic_issue',
|
||||
parent_key: null,
|
||||
title: issue.title,
|
||||
metadata: JSON.stringify({
|
||||
issueNumber: issue.issueNumber,
|
||||
pageCount: issue.pageCount,
|
||||
coverUrl: issue.coverUrl,
|
||||
isStandalone: true,
|
||||
}),
|
||||
file_path: issue.filePath,
|
||||
scanned_at: now,
|
||||
})
|
||||
issueCount++
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
// Prewarm CBZ cover thumbnails
|
||||
for (const item of items) {
|
||||
const issuesToWarm: ComicIssue[] = 'issues' in item
|
||||
? (item as ScannedComicSeries).issues.slice(0, 1)
|
||||
: [item as ComicIssue]
|
||||
|
||||
for (const issue of issuesToWarm) {
|
||||
const absPath = path.join(libraryRoot, issue.filePath)
|
||||
try {
|
||||
await getCbzThumbnailPath(absPath, library.id)
|
||||
} catch (err) {
|
||||
console.warn(`[scanner] Could not generate CBZ thumbnail for ${issue.filePath}:`, err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[scanner] comics: indexed ${items.filter((i) => 'issues' in i).length} series, ${issueCount} issues`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -263,6 +263,52 @@ export function getSeriesEpisodeTagMap(libraryId: string): Record<string, string
|
||||
return result
|
||||
}
|
||||
|
||||
// Returns seriesItemKey -> { tagIds, issueTitles } aggregated from all issues in each series.
|
||||
export function getComicsSeriesIssueMeta(
|
||||
libraryId: string
|
||||
): Record<string, { tagIds: string[]; issueTitles: string[] }> {
|
||||
const db = getDb()
|
||||
|
||||
// All issues belonging to a series (parent_key is not null)
|
||||
const issues = db
|
||||
.prepare(
|
||||
`SELECT item_key, parent_key, title
|
||||
FROM media_items
|
||||
WHERE library_id = ? AND item_type = 'comic_issue' AND parent_key IS NOT NULL`
|
||||
)
|
||||
.all(libraryId) as { item_key: string; parent_key: string; title: string | null }[]
|
||||
|
||||
const result: Record<string, { tagIds: string[]; issueTitles: string[] }> = {}
|
||||
const issueKeyToParent = new Map<string, string>()
|
||||
|
||||
for (const { item_key, parent_key, title } of issues) {
|
||||
issueKeyToParent.set(item_key, parent_key)
|
||||
const entry = (result[parent_key] ??= { tagIds: [], issueTitles: [] })
|
||||
if (title) entry.issueTitles.push(title)
|
||||
}
|
||||
|
||||
if (issueKeyToParent.size === 0) return result
|
||||
|
||||
// Tag assignments for those issues
|
||||
const tagRows = db
|
||||
.prepare(
|
||||
`SELECT mt.item_key, mt.tag_id
|
||||
FROM media_tags mt
|
||||
JOIN media_items mi ON mi.item_key = mt.item_key
|
||||
WHERE mi.library_id = ? AND mi.item_type = 'comic_issue' AND mi.parent_key IS NOT NULL`
|
||||
)
|
||||
.all(libraryId) as { item_key: string; tag_id: string }[]
|
||||
|
||||
for (const { item_key, tag_id } of tagRows) {
|
||||
const parentKey = issueKeyToParent.get(item_key)
|
||||
if (!parentKey) continue
|
||||
const entry = result[parentKey]
|
||||
if (entry && !entry.tagIds.includes(tag_id)) entry.tagIds.push(tag_id)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function removeAllAssignmentsForLibrary(libraryId: string): void {
|
||||
const db = getDb()
|
||||
db.prepare('DELETE FROM media_tags WHERE item_key LIKE ?').run(`${libraryId}:%`)
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { spawn } from 'child_process'
|
||||
import sharp from 'sharp'
|
||||
import AdmZip from 'adm-zip'
|
||||
|
||||
const CACHE_DIR = path.resolve(process.cwd(), '.thumbnails')
|
||||
const THUMBNAIL_WIDTH = 400
|
||||
@@ -221,6 +222,41 @@ export async function getOcrImagePath(
|
||||
return cacheFile
|
||||
}
|
||||
|
||||
const CBZ_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||
|
||||
/**
|
||||
* Returns the absolute path to a cached thumbnail JPEG for a CBZ archive.
|
||||
* Extracts the first image entry (natural sort) from the ZIP and resizes it.
|
||||
* Throws on failure — callers should map this to a 404.
|
||||
*/
|
||||
export async function getCbzThumbnailPath(
|
||||
absoluteFilePath: string,
|
||||
libraryId: string
|
||||
): Promise<string> {
|
||||
ensureCacheDir()
|
||||
|
||||
const key = cacheKey(libraryId, absoluteFilePath)
|
||||
const cacheFile = path.join(CACHE_DIR, key + '.jpg')
|
||||
|
||||
const cached = getCachedPath(cacheFile, absoluteFilePath)
|
||||
if (cached) return cached
|
||||
|
||||
const zip = new AdmZip(absoluteFilePath)
|
||||
const entries = zip
|
||||
.getEntries()
|
||||
.filter((e) => !e.isDirectory && CBZ_IMAGE_EXTENSIONS.has(path.extname(e.entryName).toLowerCase()))
|
||||
.sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true, sensitivity: 'base' }))
|
||||
|
||||
if (entries.length === 0) throw new Error('No image entries found in CBZ')
|
||||
|
||||
const buffer = entries[0].getData()
|
||||
const tmp = cacheFile + '.tmp'
|
||||
await sharp(buffer).resize(THUMBNAIL_WIDTH).jpeg({ quality: JPEG_QUALITY }).toFile(tmp)
|
||||
fs.renameSync(tmp, cacheFile)
|
||||
|
||||
return cacheFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to a cached thumbnail JPEG for the given file.
|
||||
* Generates it on first call (or when the source has been modified).
|
||||
|
||||
Reference in New Issue
Block a user