comic library improvements #35
@@ -14,7 +14,10 @@ interface Props {
|
||||
libraryId: string
|
||||
issue: ComicIssue
|
||||
onClose: () => void
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
onTagsChanged?: () => void
|
||||
onDeleted?: () => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
@@ -22,20 +25,52 @@ function pageUrl(libraryId: string, issueKey: string, pageIndex: number): string
|
||||
return `/api/comics/page?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}&pageIndex=${pageIndex}`
|
||||
}
|
||||
|
||||
export default function ComicIssueView({ libraryId, issue, onClose, onTagsChanged, readOnly }: Props) {
|
||||
export default function ComicIssueView({ libraryId, issue, onClose, onPrev, onNext, onTagsChanged, onDeleted, readOnly }: Props) {
|
||||
const [lightboxPage, setLightboxPage] = useState<number | null>(null)
|
||||
const [showTagPanel, setShowTagPanel] = useState(false)
|
||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && lightboxPage === null && !showTagPanel) onClose()
|
||||
if (lightboxPage !== null) return
|
||||
if (e.key === 'ArrowLeft') { onPrev?.(); return }
|
||||
if (e.key === 'ArrowRight') { onNext?.(); return }
|
||||
if (e.key === 'Escape') {
|
||||
if (menuOpen) { setMenuOpen(false); return }
|
||||
if (confirming) { setConfirming(false); return }
|
||||
if (showTagPanel) { setShowTagPanel(false); return }
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose, lightboxPage, showTagPanel])
|
||||
}, [onClose, onPrev, onNext, lightboxPage, showTagPanel, menuOpen, confirming])
|
||||
|
||||
// Close menu on outside click
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [menuOpen])
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
await fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}`, { method: 'DELETE' })
|
||||
onDeleted?.()
|
||||
} catch {
|
||||
setDeleting(false)
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pageCount = issue.pageCount
|
||||
const downloadUrl = fileApiUrl(libraryId, issue.filePath)
|
||||
@@ -49,10 +84,31 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Floating prev/next arrows */}
|
||||
{onPrev && !showTagPanel && (
|
||||
<button
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full flex items-center justify-center transition-colors"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||||
aria-label="Previous issue"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
{onNext && !showTagPanel && (
|
||||
<button
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full flex items-center justify-center transition-colors"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
|
||||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||||
aria-label="Next issue"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
|
||||
<div
|
||||
className={`${showTagPanel ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-4xl'}`}
|
||||
onClick={showTagPanel ? undefined : undefined}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-4xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
||||
@@ -88,19 +144,43 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
|
||||
🏷
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface)',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
{/* Kebab menu */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen((v) => !v) }}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-base font-bold transition-colors"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
aria-label="More options"
|
||||
title="More options"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-10 min-w-[120px]"
|
||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="flex items-center px-3 py-2 text-xs transition-colors hover:bg-black/10"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(false) }}
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
{!readOnly && (
|
||||
<button
|
||||
className="w-full text-left flex items-center px-3 py-2 text-xs transition-colors hover:bg-black/10"
|
||||
style={{ color: '#fca5a5' }}
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirming(true) }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||
@@ -112,6 +192,33 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{confirming && (
|
||||
<div
|
||||
className="flex items-center gap-3 mx-5 mt-3 px-3 py-2.5 rounded-lg text-sm flex-shrink-0"
|
||||
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
|
||||
>
|
||||
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
|
||||
Permanently delete this issue and its file?
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
className="px-2 py-1 rounded text-xs transition-colors"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="px-2 py-1 rounded text-xs font-medium transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Yes, delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cover + tags */}
|
||||
<div
|
||||
className="flex gap-5 px-5 py-4 flex-shrink-0"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { ComicIssue, ComicSeries } from '@/types'
|
||||
import ComicSeriesView from './ComicSeriesView'
|
||||
import ComicIssueView from './ComicIssueView'
|
||||
import FilterPanel from '@/components/FilterPanel'
|
||||
import TagSelector from '@/components/tags/TagSelector'
|
||||
@@ -22,7 +21,10 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [selectedSeries, setSelectedSeries] = useState<ComicSeries | null>(null)
|
||||
const [seriesIssues, setSeriesIssues] = useState<ComicIssue[]>([])
|
||||
const [seriesIssuesLoading, setSeriesIssuesLoading] = useState(false)
|
||||
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
|
||||
const [selectedIssueIndex, setSelectedIssueIndex] = useState<number | null>(null)
|
||||
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||
@@ -69,6 +71,16 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
||||
|
||||
useEffect(() => { fetchItems(1, '', true) }, [fetchItems])
|
||||
|
||||
// Fetch issues when a series is selected
|
||||
useEffect(() => {
|
||||
if (!selectedSeries) { setSeriesIssues([]); return }
|
||||
setSeriesIssuesLoading(true)
|
||||
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: ComicIssue[]) => { setSeriesIssues(data); setSeriesIssuesLoading(false) })
|
||||
.catch(() => setSeriesIssuesLoading(false))
|
||||
}, [selectedSeries, libraryId])
|
||||
|
||||
// IntersectionObserver: load next page when sentinel scrolls into view
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
@@ -151,6 +163,11 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
||||
return true
|
||||
})
|
||||
|
||||
// Flat list of issues at the current navigation level for prev/next
|
||||
const filteredIssues: ComicIssue[] = selectedSeries
|
||||
? seriesIssues
|
||||
: filtered.filter((item): item is ComicIssue => !('issueCount' in item))
|
||||
|
||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||
|
||||
return (
|
||||
@@ -186,6 +203,23 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Breadcrumb when inside a series */}
|
||||
{selectedSeries && (
|
||||
<div className="flex items-center gap-2 mb-4 text-sm">
|
||||
<button
|
||||
onClick={() => { setSelectedSeries(null); setSeriesIssues([]); setSearch('') }}
|
||||
className="transition-colors"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
All Comics
|
||||
</button>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{selectedSeries.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<LoadingGrid />
|
||||
) : error ? (
|
||||
@@ -205,38 +239,63 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{total > PAGE_SIZE && (
|
||||
{!selectedSeries && total > PAGE_SIZE && (
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Showing {filtered.length.toLocaleString()} of {total.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{filtered.map((item) =>
|
||||
'issueCount' in item ? (
|
||||
<SeriesCard
|
||||
key={item.id}
|
||||
series={item as ComicSeries}
|
||||
readOnly={readOnly}
|
||||
onClick={() => setSelectedSeries(item as ComicSeries)}
|
||||
onTagClick={(item as ComicSeries).item_key && !readOnly
|
||||
? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title })
|
||||
: undefined}
|
||||
/>
|
||||
) : (
|
||||
<IssueCard
|
||||
key={item.id}
|
||||
issue={item as ComicIssue}
|
||||
readOnly={readOnly}
|
||||
onClick={() => setSelectedIssue(item as ComicIssue)}
|
||||
onTagClick={(item as ComicIssue).item_key && !readOnly
|
||||
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title })
|
||||
: undefined}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden />
|
||||
{loadingMore && <LoadingMore />}
|
||||
{seriesIssuesLoading ? (
|
||||
<LoadingGrid />
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{selectedSeries
|
||||
? seriesIssues.map((issue) => (
|
||||
<IssueCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
readOnly={readOnly}
|
||||
onClick={() => { setSelectedIssue(issue); setSelectedIssueIndex(seriesIssues.indexOf(issue)) }}
|
||||
onTagClick={issue.item_key && !readOnly
|
||||
? () => setTagPanel({ itemKey: issue.item_key!, title: issue.title })
|
||||
: undefined}
|
||||
/>
|
||||
))
|
||||
: filtered.map((item) =>
|
||||
'issueCount' in item ? (
|
||||
<SeriesCard
|
||||
key={item.id}
|
||||
series={item as ComicSeries}
|
||||
readOnly={readOnly}
|
||||
onClick={() => { setSelectedSeries(item as ComicSeries); setSearch('') }}
|
||||
onTagClick={(item as ComicSeries).item_key && !readOnly
|
||||
? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title })
|
||||
: undefined}
|
||||
/>
|
||||
) : (
|
||||
<IssueCard
|
||||
key={item.id}
|
||||
issue={item as ComicIssue}
|
||||
readOnly={readOnly}
|
||||
onClick={() => {
|
||||
const issue = item as ComicIssue
|
||||
setSelectedIssue(issue)
|
||||
setSelectedIssueIndex(filteredIssues.indexOf(issue))
|
||||
}}
|
||||
onTagClick={(item as ComicIssue).item_key && !readOnly
|
||||
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title })
|
||||
: undefined}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{!selectedSeries && (
|
||||
<>
|
||||
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden />
|
||||
{loadingMore && <LoadingMore />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -285,22 +344,30 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSeries && (
|
||||
<ComicSeriesView
|
||||
libraryId={libraryId}
|
||||
series={selectedSeries}
|
||||
onClose={() => setSelectedSeries(null)}
|
||||
onTagsChanged={onTagsChanged}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedIssue && (
|
||||
<ComicIssueView
|
||||
libraryId={libraryId}
|
||||
issue={selectedIssue}
|
||||
onClose={() => setSelectedIssue(null)}
|
||||
onClose={() => { setSelectedIssue(null); setSelectedIssueIndex(null) }}
|
||||
onPrev={selectedIssueIndex !== null && selectedIssueIndex > 0
|
||||
? () => { setSelectedIssue(filteredIssues[selectedIssueIndex - 1]); setSelectedIssueIndex(selectedIssueIndex - 1) }
|
||||
: undefined}
|
||||
onNext={selectedIssueIndex !== null && selectedIssueIndex < filteredIssues.length - 1
|
||||
? () => { setSelectedIssue(filteredIssues[selectedIssueIndex + 1]); setSelectedIssueIndex(selectedIssueIndex + 1) }
|
||||
: undefined}
|
||||
onTagsChanged={onTagsChanged}
|
||||
onDeleted={() => {
|
||||
setSelectedIssue(null)
|
||||
setSelectedIssueIndex(null)
|
||||
fetchItems(1, search, true)
|
||||
fetchAssignments()
|
||||
if (selectedSeries) {
|
||||
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: ComicIssue[]) => setSeriesIssues(data))
|
||||
.catch(() => {})
|
||||
}
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -53,6 +53,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
|
||||
// Polling ref
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const touchStartX = useRef<number | null>(null)
|
||||
|
||||
// Determine if this is an image file (for text extraction controls)
|
||||
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
|
||||
@@ -131,10 +132,24 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
if (e.key === 'ArrowLeft') onPrev?.()
|
||||
if (e.key === 'ArrowRight') onNext?.()
|
||||
}
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX
|
||||
}
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
if (touchStartX.current === null) return
|
||||
const delta = touchStartX.current - e.changedTouches[0].clientX
|
||||
if (delta > 50) onNext?.()
|
||||
else if (delta < -50) onPrev?.()
|
||||
touchStartX.current = null
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
document.removeEventListener('touchstart', handleTouchStart)
|
||||
document.removeEventListener('touchend', handleTouchEnd)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [onClose, onPrev, onNext])
|
||||
|
||||
@@ -18,27 +18,24 @@ export async function importComicMetadata(library: Library): Promise<void> {
|
||||
const db = getDb()
|
||||
const libraryRoot = resolveLibraryRoot(library)
|
||||
|
||||
// Only process issues that have not had ComicInfo.xml imported yet.
|
||||
// Issues restored from a previous scan will already have year/genres set.
|
||||
const issues = db
|
||||
.prepare(
|
||||
`SELECT item_key, file_path, metadata FROM media_items
|
||||
WHERE library_id = ? AND item_type = 'comic_issue' AND file_path IS NOT NULL`
|
||||
WHERE library_id = ? AND item_type = 'comic_issue' AND file_path IS NOT NULL
|
||||
AND year IS NULL AND genres IS NULL`
|
||||
)
|
||||
.all(library.id) as { item_key: string; file_path: string; metadata: string | null }[]
|
||||
|
||||
if (issues.length === 0) return
|
||||
|
||||
// Load existing mappings for this library
|
||||
const mappingRows = db
|
||||
.prepare('SELECT imported_tag_name, tag_id FROM tag_mappings WHERE library_id = ?')
|
||||
.all(library.id) as { imported_tag_name: string; tag_id: string }[]
|
||||
const mappings = new Map(mappingRows.map((r) => [r.imported_tag_name, r.tag_id]))
|
||||
|
||||
// Clear existing imported tag associations for this library (they'll be re-created)
|
||||
db.prepare(
|
||||
`DELETE FROM item_imported_tags WHERE imported_tag_id IN (
|
||||
SELECT id FROM imported_tags WHERE library_id = ?
|
||||
)`
|
||||
).run(library.id)
|
||||
db.prepare('DELETE FROM imported_tags WHERE library_id = ?').run(library.id)
|
||||
|
||||
const updateItem = db.prepare(`
|
||||
UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata
|
||||
WHERE item_key = @item_key
|
||||
|
||||
@@ -552,6 +552,36 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
||||
const db = getDb()
|
||||
const now = Date.now()
|
||||
|
||||
// Save ComicInfo metadata for issues that were already imported so we can
|
||||
// restore it after the clear+upsert without re-reading any CBZ files.
|
||||
type SavedInfo = { title: string | null; year: number | null; genres: string | null; comicFields: Record<string, unknown> }
|
||||
const savedComicInfo = new Map<string, SavedInfo>()
|
||||
{
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT item_key, title, year, genres, metadata FROM media_items
|
||||
WHERE library_id = ? AND item_type = 'comic_issue'
|
||||
AND (year IS NOT NULL OR genres IS NOT NULL)`
|
||||
)
|
||||
.all(library.id) as { item_key: string; title: string | null; year: number | null; genres: string | null; metadata: string | null }[]
|
||||
for (const row of rows) {
|
||||
const meta: Record<string, unknown> = row.metadata ? (JSON.parse(row.metadata) as Record<string, unknown>) : {}
|
||||
savedComicInfo.set(row.item_key, {
|
||||
title: row.title,
|
||||
year: row.year,
|
||||
genres: row.genres,
|
||||
comicFields: {
|
||||
writer: meta.writer,
|
||||
publisher: meta.publisher,
|
||||
translator: meta.translator,
|
||||
web: meta.web,
|
||||
month: meta.month,
|
||||
day: meta.day,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
clearLibraryItems(db, library.id)
|
||||
|
||||
const upsertSeries = db.prepare(`
|
||||
@@ -648,6 +678,18 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of item_key → fresh scan metadata (needed for ComicInfo restore below).
|
||||
const freshMetaMap = new Map<string, Record<string, unknown>>()
|
||||
for (const entry of allRecords) {
|
||||
if (entry.type === 'issue') {
|
||||
const rec = entry.rec as { item_key: unknown; metadata: unknown }
|
||||
freshMetaMap.set(
|
||||
String(rec.item_key),
|
||||
JSON.parse(String(rec.metadata)) as Record<string, unknown>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -662,6 +704,23 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
||||
await new Promise<void>((r) => setImmediate(r))
|
||||
}
|
||||
|
||||
// Restore previously-imported ComicInfo data for issues that still exist on disk.
|
||||
// Merges scan-derived fields (pageCount, coverUrl) with the saved ComicInfo fields
|
||||
// so neither set of data is lost. Title from ComicInfo is also preserved.
|
||||
if (savedComicInfo.size > 0) {
|
||||
const restoreStmt = db.prepare(
|
||||
'UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata WHERE item_key = @item_key'
|
||||
)
|
||||
db.transaction(() => {
|
||||
for (const [item_key, saved] of savedComicInfo) {
|
||||
const freshMeta = freshMetaMap.get(item_key)
|
||||
if (!freshMeta) continue // file was removed from disk
|
||||
const merged = { ...freshMeta, ...saved.comicFields }
|
||||
restoreStmt.run({ item_key, title: saved.title, year: saved.year, genres: saved.genres, metadata: JSON.stringify(merged) })
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user