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

View File

@@ -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
// ---------------------------------------------------------------------------