don't block during scan
This commit is contained in:
@@ -126,10 +126,18 @@ export function scanComicsLibrary(
|
||||
return results.sort((a, b) => naturalCompare(a.title, b.title))
|
||||
}
|
||||
|
||||
// comicsFromDb returns series + standalone issues for the top-level grid.
|
||||
function escapeLike(s: string): string {
|
||||
return `%${s.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`
|
||||
}
|
||||
|
||||
// comicsFromDb returns series + standalone issues for the top-level grid, paginated.
|
||||
// Series issues are retrieved separately via comicIssuesFromDb.
|
||||
export function comicsFromDb(libraryId: string): (ComicIssue | ComicSeries)[] {
|
||||
export function comicsFromDb(
|
||||
libraryId: string,
|
||||
opts: { page: number; pageSize: number; search?: string }
|
||||
): { items: (ComicIssue | ComicSeries)[]; total: number } {
|
||||
const db = getDb()
|
||||
const offset = (opts.page - 1) * opts.pageSize
|
||||
|
||||
type DbRow = {
|
||||
item_key: string
|
||||
@@ -140,59 +148,63 @@ export function comicsFromDb(libraryId: string): (ComicIssue | ComicSeries)[] {
|
||||
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 baseWhere = `
|
||||
WHERE library_id = ?
|
||||
AND (item_type = 'comic_series' OR (item_type = 'comic_issue' AND parent_key IS NULL))
|
||||
`
|
||||
|
||||
const seriesMap = new Map<string, ComicSeries>()
|
||||
const standaloneIssues: ComicIssue[] = []
|
||||
const total: number = opts.search
|
||||
? (db
|
||||
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'`)
|
||||
.get(libraryId, escapeLike(opts.search)) as { cnt: number }).cnt
|
||||
: (db
|
||||
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere}`)
|
||||
.get(libraryId) as { cnt: number }).cnt
|
||||
|
||||
for (const row of allRows) {
|
||||
if (row.item_type !== 'comic_series') continue
|
||||
const rows: DbRow[] = opts.search
|
||||
? db
|
||||
.prepare(
|
||||
`SELECT item_key, item_type, parent_key, title, metadata, file_path
|
||||
FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'
|
||||
ORDER BY title LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(libraryId, escapeLike(opts.search), opts.pageSize, offset) as DbRow[]
|
||||
: db
|
||||
.prepare(
|
||||
`SELECT item_key, item_type, parent_key, title, metadata, file_path
|
||||
FROM media_items ${baseWhere}
|
||||
ORDER BY title LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(libraryId, opts.pageSize, offset) as DbRow[]
|
||||
|
||||
const items: (ComicIssue | ComicSeries)[] = []
|
||||
for (const row of rows) {
|
||||
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
|
||||
if (row.item_type === 'comic_series') {
|
||||
const idPart = row.item_key.split(':comic_series:')[1] ?? row.item_key
|
||||
items.push({
|
||||
id: idPart,
|
||||
item_key: row.item_key,
|
||||
title: row.title ?? decodeURIComponent(idPart),
|
||||
coverUrl: meta.coverUrl ?? null,
|
||||
issueCount: meta.issueCount ?? 0,
|
||||
} as ComicSeries)
|
||||
} else {
|
||||
standaloneIssues.push(issue)
|
||||
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
|
||||
items.push({
|
||||
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 ?? true,
|
||||
} as ComicIssue)
|
||||
}
|
||||
}
|
||||
|
||||
const results: (ComicIssue | ComicSeries)[] = [
|
||||
...Array.from(seriesMap.values()),
|
||||
...standaloneIssues,
|
||||
]
|
||||
return results.sort((a, b) => naturalCompare(a.title, b.title))
|
||||
return { items, total }
|
||||
}
|
||||
|
||||
export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIssue[] {
|
||||
|
||||
@@ -110,6 +110,7 @@ function initDb(db: Database.Database): void {
|
||||
migrateLibrariesAddComics(db)
|
||||
migrateComicItemTypes(db)
|
||||
migrateImportedTags(db)
|
||||
migrateComicsIndex(db)
|
||||
seedAppSettings(db)
|
||||
}
|
||||
|
||||
@@ -447,3 +448,10 @@ function migrateImportedTags(db: Database.Database): void {
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
function migrateComicsIndex(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS media_items_library_type_title
|
||||
ON media_items(library_id, item_type, title);
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -573,29 +573,37 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
||||
scanned_at = excluded.scanned_at
|
||||
`)
|
||||
|
||||
type SeriesRec = Parameters<typeof upsertSeries.run>[0]
|
||||
type IssueRec = Parameters<typeof upsertIssue.run>[0]
|
||||
type BatchEntry = { type: 'series'; rec: SeriesRec } | { type: 'issue'; rec: IssueRec }
|
||||
|
||||
// Collect all records before touching the DB so we can batch-insert with event-loop yields.
|
||||
// Note: between clearLibraryItems and the final batch, the library will appear partially
|
||||
// populated — acceptable for a background scan.
|
||||
const allRecords: BatchEntry[] = []
|
||||
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({
|
||||
for (const item of items) {
|
||||
if ('issues' in item) {
|
||||
const series = item as ScannedComicSeries
|
||||
const seriesKey = `${library.id}:comic_series:${series.id}`
|
||||
allRecords.push({
|
||||
type: 'series',
|
||||
rec: {
|
||||
library_id: library.id,
|
||||
item_key: seriesKey,
|
||||
item_type: 'comic_series',
|
||||
title: series.title,
|
||||
metadata: JSON.stringify({
|
||||
issueCount: series.issueCount,
|
||||
coverUrl: series.coverUrl,
|
||||
}),
|
||||
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({
|
||||
},
|
||||
})
|
||||
for (const issue of series.issues) {
|
||||
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
||||
allRecords.push({
|
||||
type: 'issue',
|
||||
rec: {
|
||||
library_id: library.id,
|
||||
item_key: issueKey,
|
||||
item_type: 'comic_issue',
|
||||
@@ -609,13 +617,16 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
||||
}),
|
||||
file_path: issue.filePath,
|
||||
scanned_at: now,
|
||||
})
|
||||
issueCount++
|
||||
}
|
||||
} else {
|
||||
const issue = item as ComicIssue
|
||||
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
||||
upsertIssue.run({
|
||||
},
|
||||
})
|
||||
issueCount++
|
||||
}
|
||||
} else {
|
||||
const issue = item as ComicIssue
|
||||
const issueKey = `${library.id}:comic_issue:${issue.id}`
|
||||
allRecords.push({
|
||||
type: 'issue',
|
||||
rec: {
|
||||
library_id: library.id,
|
||||
item_key: issueKey,
|
||||
item_type: 'comic_issue',
|
||||
@@ -629,13 +640,27 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
||||
}),
|
||||
file_path: issue.filePath,
|
||||
scanned_at: now,
|
||||
})
|
||||
issueCount++
|
||||
}
|
||||
},
|
||||
})
|
||||
issueCount++
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// Prewarm CBZ cover thumbnails
|
||||
// Insert in batches of 500, yielding the event loop between batches so the app
|
||||
// remains responsive to HTTP requests during a large scan.
|
||||
const BATCH_SIZE = 500
|
||||
for (let i = 0; i < allRecords.length; i += BATCH_SIZE) {
|
||||
const batch = allRecords.slice(i, i + BATCH_SIZE)
|
||||
db.transaction(() => {
|
||||
for (const entry of batch) {
|
||||
if (entry.type === 'series') upsertSeries.run(entry.rec)
|
||||
else upsertIssue.run(entry.rec)
|
||||
}
|
||||
})()
|
||||
await new Promise<void>((r) => setImmediate(r))
|
||||
}
|
||||
|
||||
// Prewarm CBZ cover thumbnails — fire-and-forget so they don't block scan completion.
|
||||
for (const item of items) {
|
||||
const issuesToWarm: ComicIssue[] = 'issues' in item
|
||||
? (item as ScannedComicSeries).issues.slice(0, 1)
|
||||
@@ -643,11 +668,9 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
||||
|
||||
for (const issue of issuesToWarm) {
|
||||
const absPath = path.join(libraryRoot, issue.filePath)
|
||||
try {
|
||||
await getCbzThumbnailPath(absPath, library.id)
|
||||
} catch (err) {
|
||||
getCbzThumbnailPath(absPath, library.id).catch((err) => {
|
||||
console.warn(`[scanner] Could not generate CBZ thumbnail for ${issue.filePath}:`, err instanceof Error ? err.message : err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user