add manga library
This commit is contained in:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user