Merge pull request 'import-comicinfoxml' (#32) from import-comicinfoxml into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 57s

Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/32
This commit is contained in:
2026-04-20 03:09:44 +00:00
14 changed files with 1657 additions and 3 deletions

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server'
import { getImportedTagsForLibrary } from '@/lib/comic-metadata'
import { requireAdmin } from '@/lib/auth'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const libraryId = request.nextUrl.searchParams.get('libraryId')
if (!libraryId) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const tags = getImportedTagsForLibrary(libraryId)
return NextResponse.json(tags)
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getLibrary } from '@/lib/libraries'
import { getDb } from '@/lib/db'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id: libraryId } = await params
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'comics') {
return NextResponse.json({ error: 'Only comics libraries support bulk rename' }, { status: 400 })
}
const body = await request.json()
const { pattern, preview } = body as { pattern: string; preview?: boolean }
if (!pattern || typeof pattern !== 'string') {
return NextResponse.json({ error: 'Pattern is required' }, { status: 400 })
}
// Validate regex
let regex: RegExp
try {
regex = new RegExp(pattern, 'g')
} catch {
return NextResponse.json({ error: 'Invalid regex pattern' }, { status: 400 })
}
const db = getDb()
const rows = db
.prepare(
`SELECT item_key, title FROM media_items
WHERE library_id = ? AND item_type IN ('comic_series', 'comic_issue')`
)
.all(libraryId) as { item_key: string; title: string }[]
const changes: { itemKey: string; oldTitle: string; newTitle: string }[] = []
for (const row of rows) {
// Reset lastIndex since we reuse the regex with 'g' flag
regex.lastIndex = 0
const newTitle = row.title.replace(regex, '').trim()
if (newTitle && newTitle !== row.title) {
changes.push({ itemKey: row.item_key, oldTitle: row.title, newTitle })
}
}
if (preview) {
return NextResponse.json({ changes })
}
// Apply
const stmt = db.prepare('UPDATE media_items SET title = ? WHERE item_key = ?')
db.transaction(() => {
for (const c of changes) {
stmt.run(c.newTitle, c.itemKey)
}
})()
return NextResponse.json({ updated: changes.length })
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary } from '@/lib/libraries'
import { importComicMetadata } from '@/lib/comic-metadata'
import { requireAdmin } from '@/lib/auth'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
const library = getLibrary(id)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'comics') {
return NextResponse.json({ error: 'Metadata import is only supported for comic libraries' }, { status: 400 })
}
// Fire-and-forget
void Promise.resolve().then(() => {
try {
importComicMetadata(library)
} catch (err) {
console.error(`[import-metadata] Error importing metadata for "${library.name}":`, err)
}
})
return new NextResponse(null, { status: 202 })
}

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { deleteTagMapping } from '@/lib/comic-metadata'
import { requireAdmin } from '@/lib/auth'
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
try {
deleteTagMapping(id)
return new NextResponse(null, { status: 204 })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete mapping'
return NextResponse.json({ error: message }, { status: 404 })
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { getTagMappingsForLibrary, createTagMapping } from '@/lib/comic-metadata'
import { requireAdmin } from '@/lib/auth'
export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const libraryId = request.nextUrl.searchParams.get('libraryId')
if (!libraryId) {
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
}
const mappings = getTagMappingsForLibrary(libraryId)
return NextResponse.json(mappings)
}
export async function POST(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
let body: { libraryId?: string; importedTagName?: string; tagId?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { libraryId, importedTagName, tagId } = body
if (!libraryId || !importedTagName || !tagId) {
return NextResponse.json(
{ error: 'libraryId, importedTagName, and tagId are required' },
{ status: 400 }
)
}
try {
const mapping = createTagMapping(libraryId, importedTagName, tagId)
return NextResponse.json(mapping, { status: 201 })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create mapping'
return NextResponse.json({ error: message }, { status: 400 })
}
}

View File

@@ -107,6 +107,8 @@ function LibraryRow({
const [confirming, setConfirming] = useState(false)
const [removing, setRemoving] = useState(false)
const [uploadingCover, setUploadingCover] = useState(false)
const [importing, setImporting] = useState<'idle' | 'running' | 'done'>('idle')
const [showBulkRename, setShowBulkRename] = useState(false)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -209,6 +211,41 @@ function LibraryRow({
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{library.type === 'comics' && (
<>
<button
onClick={() => {
setImporting('running')
fetch(`/api/libraries/${encodeURIComponent(library.id)}/import-metadata`, { method: 'POST' })
.then(() => {
setImporting('done')
setTimeout(() => setImporting('idle'), 3000)
})
.catch(() => setImporting('idle'))
}}
disabled={importing === 'running'}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => {
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{importing === 'running' ? 'Importing…' : importing === 'done' ? 'Imported ✓' : 'Import Metadata'}
</button>
<button
onClick={() => setShowBulkRename(true)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Bulk Rename
</button>
</>
)}
{library.coverExt && (
<button
onClick={handleRemoveCover}
@@ -255,6 +292,209 @@ function LibraryRow({
{removing ? 'Removing…' : confirming ? 'Confirm?' : 'Remove'}
</button>
</div>
{showBulkRename && (
<BulkRenameModal libraryId={library.id} onClose={() => setShowBulkRename(false)} />
)}
</div>
)
}
// ─── Bulk Rename Modal ────────────────────────────────────────────────────────
function BulkRenameModal({ libraryId, onClose }: { libraryId: string; onClose: () => void }) {
const [pattern, setPattern] = useState('')
const [preview, setPreview] = useState<{ itemKey: string; oldTitle: string; newTitle: string }[] | null>(null)
const [loading, setLoading] = useState(false)
const [applying, setApplying] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<string | null>(null)
const handlePreview = async () => {
if (!pattern.trim()) return
setError(null)
setPreview(null)
setResult(null)
setLoading(true)
try {
const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: pattern.trim(), preview: true }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Failed to preview')
} else {
setPreview(data.changes ?? [])
}
} catch {
setError('Network error')
} finally {
setLoading(false)
}
}
const handleApply = async () => {
if (!pattern.trim()) return
setError(null)
setApplying(true)
try {
const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: pattern.trim() }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Failed to apply')
} else {
setResult(`Updated ${data.updated} title${data.updated === 1 ? '' : 's'}`)
setPreview(null)
}
} catch {
setError('Network error')
} finally {
setApplying(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={onClose}
>
<div
className="w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col"
style={{
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
maxHeight: '80vh',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-4 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div>
<p className="font-medium" style={{ color: 'var(--text-primary)' }}>Bulk Rename</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
Enter a regex pattern to remove from comic titles
</p>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
</button>
</div>
{/* Body */}
<div className="px-5 py-4 overflow-y-auto flex-1">
{/* Pattern input */}
<div className="flex gap-2 mb-4">
<input
type="text"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handlePreview() }}
placeholder="e.g. \[English\]|\{doujin-moe\.us\}"
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none font-mono"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
autoFocus
/>
<button
onClick={handlePreview}
disabled={!pattern.trim() || loading}
className="text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{loading ? 'Loading…' : 'Preview'}
</button>
</div>
{error && (
<p
className="text-xs mb-3 px-3 py-2 rounded-lg"
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
>
{error}
</p>
)}
{result && (
<p
className="text-xs mb-3 px-3 py-2 rounded-lg"
style={{ backgroundColor: '#14532d33', color: '#86efac' }}
>
{result}
</p>
)}
{/* Preview list */}
{preview !== null && (
preview.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
No titles match this pattern.
</p>
) : (
<div>
<p className="text-xs mb-2" style={{ color: 'var(--text-secondary)' }}>
{preview.length} title{preview.length === 1 ? '' : 's'} will be updated:
</p>
<div
className="rounded-lg border divide-y overflow-hidden"
style={{ borderColor: 'var(--border)' }}
>
{preview.map((c) => (
<div key={c.itemKey} className="px-3 py-2">
<p className="text-xs line-through" style={{ color: 'var(--text-secondary)' }}>
{c.oldTitle}
</p>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>
{c.newTitle}
</p>
</div>
))}
</div>
</div>
)
)}
</div>
{/* Footer */}
{preview && preview.length > 0 && (
<div
className="flex items-center justify-end gap-2 px-5 py-3 flex-shrink-0"
style={{ borderTop: '1px solid var(--border)' }}
>
<button
onClick={onClose}
className="text-xs px-3 py-2 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
<button
onClick={handleApply}
disabled={applying}
className="text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
>
{applying ? 'Applying…' : `Apply to ${preview.length} title${preview.length === 1 ? '' : 's'}`}
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,769 @@
'use client'
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
import { useParams } from 'next/navigation'
import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types'
export default function TagMappingsPage() {
const params = useParams()
const libraryId = params.id as string
const [library, setLibrary] = useState<Library | null>(null)
const [importedTags, setImportedTags] = useState<ImportedTag[]>([])
const [mappings, setMappings] = useState<TagMapping[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [categories, setCategories] = useState<TagCategory[]>([])
const [loading, setLoading] = useState(true)
const [prefixMappings, setPrefixMappings] = useState<Record<string, string>>({})
const [ignoredTags, setIgnoredTags] = useState<Set<string>>(new Set())
// Load prefix mappings and ignored tags from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(`prefix-mappings-${libraryId}`)
if (stored) setPrefixMappings(JSON.parse(stored))
} catch { /* ignore */ }
try {
const stored = localStorage.getItem(`ignored-tags-${libraryId}`)
if (stored) setIgnoredTags(new Set(JSON.parse(stored)))
} catch { /* ignore */ }
}, [libraryId])
const updatePrefixMappings = useCallback((next: Record<string, string>) => {
setPrefixMappings(next)
try {
localStorage.setItem(`prefix-mappings-${libraryId}`, JSON.stringify(next))
} catch { /* ignore */ }
}, [libraryId])
const updateIgnoredTags = useCallback((next: Set<string>) => {
setIgnoredTags(next)
try {
localStorage.setItem(`ignored-tags-${libraryId}`, JSON.stringify([...next]))
} catch { /* ignore */ }
}, [libraryId])
const refresh = () => {
Promise.all([
fetch(`/api/imported-tags?libraryId=${encodeURIComponent(libraryId)}`).then((r) => r.json()),
fetch(`/api/tag-mappings?libraryId=${encodeURIComponent(libraryId)}`).then((r) => r.json()),
fetch('/api/tags/items').then((r) => r.json()),
fetch('/api/tags/categories').then((r) => r.json()),
fetch('/api/libraries').then((r) => r.json()),
])
.then(([imported, maps, tgs, cats, libs]: [ImportedTag[], TagMapping[], Tag[], TagCategory[], Library[]]) => {
setImportedTags(imported)
setMappings(maps)
setTags(tgs)
setCategories(cats)
setLibrary(libs.find((l) => l.id === libraryId) ?? null)
setLoading(false)
})
.catch(() => setLoading(false))
}
useEffect(() => {
refresh()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libraryId])
const tagsByCategory = categories.map((cat) => ({
category: cat,
tags: tags.filter((t) => t.categoryId === cat.id),
})).filter((g) => g.tags.length > 0)
const visibleTags = importedTags.filter((t) => !ignoredTags.has(t.name))
const hiddenTags = importedTags.filter((t) => ignoredTags.has(t.name))
return (
<div className="max-w-2xl">
<div className="flex items-center gap-2 mb-1">
<a
href="/manage/tags"
className="text-sm no-underline transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Tags
</a>
</div>
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
Tag Mappings{library ? `${library.name}` : ''}
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Map imported tags from ComicInfo.xml files to your tag categories.
</p>
{loading ? (
<Section title="Unmapped Tags">
<LoadingRows />
</Section>
) : (
<>
<PrefixMappingsSection
categories={categories}
importedTags={importedTags}
prefixMappings={prefixMappings}
onUpdate={updatePrefixMappings}
/>
<Section title="Unmapped Tags">
{visibleTags.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
{importedTags.length === 0
? 'No unmapped imported tags. All tags have been mapped or no ComicInfo.xml tags were found.'
: 'All unmapped tags are hidden. Check the ignored tags section below.'}
</p>
) : (
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{visibleTags.map((it) => (
<ImportedTagRow
key={it.id}
importedTag={it}
libraryId={libraryId}
tagsByCategory={tagsByCategory}
categories={categories}
prefixMappings={prefixMappings}
onMapped={refresh}
onIgnore={() => updateIgnoredTags(new Set([...ignoredTags, it.name]))}
/>
))}
</div>
)}
</Section>
{hiddenTags.length > 0 && (
<IgnoredTagsSection
tags={hiddenTags}
onUnignore={(name) => {
const next = new Set(ignoredTags)
next.delete(name)
updateIgnoredTags(next)
}}
/>
)}
<Section title="Saved Mappings">
{mappings.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
No saved mappings yet. Map imported tags above to create persistent mappings.
</p>
) : (
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{mappings.map((m) => (
<MappingRow key={m.id} mapping={m} onDeleted={refresh} />
))}
</div>
)}
</Section>
</>
)}
</div>
)
}
// ─── Prefix Mappings Section ──────────────────────────────────────────────────
function PrefixMappingsSection({
categories,
importedTags,
prefixMappings,
onUpdate,
}: {
categories: TagCategory[]
importedTags: ImportedTag[]
prefixMappings: Record<string, string>
onUpdate: (next: Record<string, string>) => void
}) {
const [newPrefix, setNewPrefix] = useState('')
const [newCategoryId, setNewCategoryId] = useState('')
// Detect prefixes from imported tags that aren't yet mapped
const detectedPrefixes = Array.from(
new Set(
importedTags
.map((t) => {
const idx = t.name.indexOf(': ')
return idx > 0 ? t.name.slice(0, idx).trim().toLowerCase() : null
})
.filter((p): p is string => p !== null)
)
).filter((p) => !(p in prefixMappings)).sort()
const catMap = new Map(categories.map((c) => [c.id, c.name]))
const entries = Object.entries(prefixMappings)
const handleAdd = () => {
const key = newPrefix.trim().toLowerCase()
if (!key || !newCategoryId) return
onUpdate({ ...prefixMappings, [key]: newCategoryId })
setNewPrefix('')
setNewCategoryId('')
}
const handleRemove = (key: string) => {
const next = { ...prefixMappings }
delete next[key]
onUpdate(next)
}
return (
<Section title="Prefix Mappings">
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
Map tag prefixes (e.g. &quot;language&quot; in &quot;language: english&quot;) to categories.
When creating a new tag, the category and name will auto-fill.
</p>
{/* Existing mappings */}
{entries.length > 0 && (
<div className="divide-y mb-3" style={{ borderColor: 'var(--border)' }}>
{entries.map(([prefix, catId]) => (
<div key={prefix} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-mono"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
>
{prefix}:
</span>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}></span>
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>
{catMap.get(catId) ?? catId}
</span>
<div className="flex-1" />
<button
onClick={() => handleRemove(prefix)}
className="text-xs px-2 py-1 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
Remove
</button>
</div>
))}
</div>
)}
{/* Add row */}
<div className="flex items-center gap-2">
<input
type="text"
value={newPrefix}
onChange={(e) => setNewPrefix(e.target.value)}
placeholder="prefix"
className="rounded-lg px-2 py-1.5 text-xs font-mono outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
width: 100,
}}
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd() }}
/>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}></span>
<select
value={newCategoryId}
onChange={(e) => setNewCategoryId(e.target.value)}
className="rounded-lg px-2 py-1.5 text-xs outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minWidth: 130,
}}
>
<option value="">Category</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<button
onClick={handleAdd}
disabled={!newPrefix.trim() || !newCategoryId}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
Add
</button>
</div>
{/* Suggestions */}
{detectedPrefixes.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Detected:</span>
{detectedPrefixes.map((p) => (
<button
key={p}
onClick={() => setNewPrefix(p)}
className="text-xs px-2 py-0.5 rounded-full transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
{p}
</button>
))}
</div>
)}
</Section>
)
}
// ─── Imported Tag Row ─────────────────────────────────────────────────────────
function ImportedTagRow({
importedTag,
libraryId,
tagsByCategory,
categories,
prefixMappings,
onMapped,
onIgnore,
}: {
importedTag: ImportedTag
libraryId: string
tagsByCategory: { category: TagCategory; tags: Tag[] }[]
categories: TagCategory[]
prefixMappings: Record<string, string>
onMapped: () => void
onIgnore: () => void
}) {
// Auto-match: if prefix mapping exists, find a tag in that category matching the stripped name
const autoMatchedTagId = useMemo(() => {
const colonIdx = importedTag.name.indexOf(': ')
if (colonIdx <= 0) return ''
const prefix = importedTag.name.slice(0, colonIdx).trim().toLowerCase()
const mappedCategoryId = prefixMappings[prefix]
if (!mappedCategoryId) return ''
const strippedName = importedTag.name.slice(colonIdx + 2).trim().toLowerCase()
const group = tagsByCategory.find((g) => g.category.id === mappedCategoryId)
const match = group?.tags.find((t) => t.name.toLowerCase() === strippedName)
return match?.id ?? ''
}, [importedTag.name, prefixMappings, tagsByCategory])
const [selectedTagId, setSelectedTagId] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [newTagName, setNewTagName] = useState(importedTag.name)
const [newTagCategoryId, setNewTagCategoryId] = useState('')
const [creatingTag, setCreatingTag] = useState(false)
// Apply auto-match when it changes (e.g. prefix mappings updated)
useEffect(() => {
if (autoMatchedTagId) setSelectedTagId(autoMatchedTagId)
}, [autoMatchedTagId])
const startCreating = () => {
// Apply prefix mapping defaults if the imported tag has a colon prefix
const colonIdx = importedTag.name.indexOf(': ')
if (colonIdx > 0) {
const prefix = importedTag.name.slice(0, colonIdx).trim().toLowerCase()
const mappedCategoryId = prefixMappings[prefix]
if (mappedCategoryId) {
setNewTagCategoryId(mappedCategoryId)
setNewTagName(importedTag.name.slice(colonIdx + 2).trim())
setCreating(true)
return
}
}
setNewTagName(importedTag.name)
setNewTagCategoryId('')
setCreating(true)
}
const handleMap = async () => {
if (!selectedTagId) return
setError(null)
setSaving(true)
try {
const res = await fetch('/api/tag-mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
libraryId,
importedTagName: importedTag.name,
tagId: selectedTagId,
}),
})
if (!res.ok) {
const data = await res.json()
setError(data.error ?? 'Failed to save mapping')
setSaving(false)
return
}
onMapped()
} catch {
setError('Network error')
setSaving(false)
}
}
const handleCreateAndMap = async () => {
if (!newTagName.trim() || !newTagCategoryId) return
setError(null)
setCreatingTag(true)
try {
const createRes = await fetch('/api/tags/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newTagName.trim(), categoryId: newTagCategoryId }),
})
if (!createRes.ok) {
const data = await createRes.json()
setError(data.error ?? 'Failed to create tag')
setCreatingTag(false)
return
}
const newTag = await createRes.json()
const mapRes = await fetch('/api/tag-mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
libraryId,
importedTagName: importedTag.name,
tagId: newTag.id,
}),
})
if (!mapRes.ok) {
const data = await mapRes.json()
setError(data.error ?? 'Failed to save mapping')
setCreatingTag(false)
return
}
onMapped()
} catch {
setError('Network error')
setCreatingTag(false)
}
}
return (
<div className="py-3 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
{/* Left: imported tag name + item count */}
<div className="flex-1 min-w-0">
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
>
{importedTag.name}
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
({importedTag.itemCount})
</span>
</span>
</div>
{!creating ? (
<>
{/* Right: tag picker + map button + new button */}
<select
value={selectedTagId}
onChange={(e) => setSelectedTagId(e.target.value)}
className="rounded-lg px-2 py-1.5 text-xs outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minWidth: 160,
}}
>
<option value="">Select tag</option>
{tagsByCategory.map((group) => (
<optgroup key={group.category.id} label={group.category.name}>
{group.tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</optgroup>
))}
</select>
<button
onClick={handleMap}
disabled={!selectedTagId || saving}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{saving ? 'Mapping…' : 'Map'}
</button>
<button
onClick={startCreating}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
title="Create a new tag and map it"
>
+ New
</button>
<button
onClick={onIgnore}
className="text-xs px-2 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
title="Hide this tag"
>
Ignore
</button>
</>
) : (
<>
{/* Inline create: category picker + name input + create & map button */}
<select
value={newTagCategoryId}
onChange={(e) => setNewTagCategoryId(e.target.value)}
className="rounded-lg px-2 py-1.5 text-xs outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minWidth: 120,
}}
>
<option value="">Category</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
<input
type="text"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
placeholder="Tag name"
className="rounded-lg px-2 py-1.5 text-xs outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
width: 120,
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateAndMap()
if (e.key === 'Escape') setCreating(false)
}}
autoFocus
/>
<button
onClick={handleCreateAndMap}
disabled={!newTagName.trim() || !newTagCategoryId || creatingTag}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{creatingTag ? 'Creating…' : 'Create & Map'}
</button>
<button
onClick={() => setCreating(false)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
</>
)}
</div>
{error && (
<p className="text-xs mt-1.5 px-3 py-1 rounded-lg" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
{error}
</p>
)}
</div>
)
}
// ─── Ignored Tags Section ─────────────────────────────────────────────────────
function IgnoredTagsSection({
tags,
onUnignore,
}: {
tags: ImportedTag[]
onUnignore: (name: string) => void
}) {
const [expanded, setExpanded] = useState(false)
return (
<div className="mb-10">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 mb-3 group"
style={{ color: 'var(--text-secondary)' }}
>
<span className="text-xs transition-transform" style={{ display: 'inline-block', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
</span>
<span className="text-xs font-semibold uppercase tracking-wider">
Ignored Tags ({tags.length})
</span>
</button>
{expanded && (
<div
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div className="px-5 py-4">
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{tags.map((t) => (
<div key={t.id} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
{t.name}
<span className="text-xs" style={{ opacity: 0.6 }}>({t.itemCount})</span>
</span>
<div className="flex-1" />
<button
onClick={() => onUnignore(t.name)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Unignore
</button>
</div>
))}
</div>
</div>
</div>
)}
</div>
)
}
// ─── Mapping Row ──────────────────────────────────────────────────────────────
function MappingRow({ mapping, onDeleted }: { mapping: TagMapping; onDeleted: () => void }) {
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleDeleteClick = () => {
if (!confirming) {
setConfirming(true)
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
return
}
if (cancelRef.current) clearTimeout(cancelRef.current)
setDeleting(true)
fetch(`/api/tag-mappings/${encodeURIComponent(mapping.id)}`, { method: 'DELETE' })
.then(() => onDeleted())
.catch(() => setDeleting(false))
}
return (
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
>
{mapping.importedTagName}
</span>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}></span>
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{mapping.categoryName}: {mapping.tagName}
</span>
<div className="flex-1" />
{confirming && (
<button
onClick={() => {
if (cancelRef.current) clearTimeout(cancelRef.current)
setConfirming(false)
}}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
)}
<button
onClick={handleDeleteClick}
disabled={deleting}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
}}
onMouseEnter={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
}
}}
onMouseLeave={(e) => {
if (!confirming) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}
}}
>
{deleting ? 'Deleting…' : confirming ? 'Confirm?' : 'Delete'}
</button>
</div>
)
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-10">
<h2
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--text-secondary)' }}
>
{title}
</h2>
<div
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div className="px-5 py-4">{children}</div>
</div>
</div>
)
}
function LoadingRows() {
return (
<div className="flex flex-col gap-3">
{[70, 50, 85].map((w) => (
<div key={w} className="flex items-center gap-3">
<div
className="h-4 rounded animate-pulse"
style={{ width: `${w}%`, backgroundColor: 'var(--border)' }}
/>
</div>
))}
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import type { Tag, TagCategory } from '@/types'
import type { Tag, TagCategory, Library, ImportedTag } from '@/types'
// ─── Main Page ────────────────────────────────────────────────────────────────
@@ -62,6 +62,8 @@ export default function ManageTagsPage() {
<Section title="Add a Category">
<AddCategoryForm onAdded={refresh} />
</Section>
<ImportedTagMappingsSection />
</div>
)
}
@@ -480,6 +482,80 @@ function AddCategoryForm({ onAdded }: { onAdded: () => void }) {
)
}
// ─── Imported Tag Mappings Section ────────────────────────────────────────────
function ImportedTagMappingsSection() {
const [libraries, setLibraries] = useState<Library[]>([])
const [tagCounts, setTagCounts] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/libraries')
.then((r) => r.json())
.then(async (libs: Library[]) => {
const comicLibs = libs.filter((l) => l.type === 'comics')
setLibraries(comicLibs)
const counts: Record<string, number> = {}
await Promise.all(
comicLibs.map(async (lib) => {
const tags: ImportedTag[] = await fetch(
`/api/imported-tags?libraryId=${encodeURIComponent(lib.id)}`
).then((r) => r.json())
counts[lib.id] = tags.length
})
)
setTagCounts(counts)
setLoading(false)
})
.catch(() => setLoading(false))
}, [])
if (loading) {
return (
<Section title="Imported Tag Mappings">
<LoadingRows />
</Section>
)
}
if (libraries.length === 0) {
return (
<Section title="Imported Tag Mappings">
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
No comic libraries configured. Add a comic library to import tags from ComicInfo.xml files.
</p>
</Section>
)
}
return (
<Section title="Imported Tag Mappings">
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{libraries.map((lib) => (
<div key={lib.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
<span className="flex-1 font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
{lib.name}
<span className="ml-2 font-normal text-xs" style={{ color: 'var(--text-secondary)' }}>
{tagCounts[lib.id] ?? 0} imported tag{(tagCounts[lib.id] ?? 0) === 1 ? '' : 's'}
</span>
</span>
<a
href={`/manage/tags/mappings/${encodeURIComponent(lib.id)}`}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors no-underline"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
>
Manage Mappings
</a>
</div>
))}
</div>
</Section>
)
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {

View File

@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
import type { ComicIssue } from '@/types'
import ImageLightbox from '@/components/mixed/ImageLightbox'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
function fileApiUrl(libraryId: string, relativePath: string): string {
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
@@ -24,6 +25,7 @@ function pageUrl(libraryId: string, issueKey: string, pageIndex: number): string
export default function ComicIssueView({ libraryId, issue, onClose, onTagsChanged, readOnly }: Props) {
const [lightboxPage, setLightboxPage] = useState<number | null>(null)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
// Close on Escape
@@ -110,6 +112,50 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
</div>
</div>
{/* Cover + tags */}
<div
className="flex gap-5 px-5 py-4 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div
className="flex-shrink-0 rounded-lg overflow-hidden"
style={{ width: 140, aspectRatio: '2/3', background: 'var(--border)' }}
>
{issue.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={issue.coverUrl}
alt={issue.title}
className="w-full h-full object-cover"
/>
) : pageCount > 0 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={pageUrl(libraryId, issueKey, 0)}
alt={issue.title}
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full flex items-center justify-center text-3xl"
style={{ color: 'var(--text-secondary)' }}
>
📖
</div>
)}
</div>
<div className="flex-1 min-w-0 pt-1">
<p className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
{issue.item_key ? (
<AssignedTagBadges itemKey={issueKey} refreshKey={tagRefreshKey} />
) : (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No tags</p>
)}
</div>
</div>
{/* Page grid */}
<div className="overflow-y-auto flex-1 p-4" ref={gridRef}>
{pageCount === 0 ? (
@@ -155,7 +201,7 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
itemKey={issueKey}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={onTagsChanged}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
/>
)}
@@ -170,7 +216,7 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
onPrev={lightboxPage > 0 ? () => setLightboxPage((p) => (p ?? 1) - 1) : undefined}
onNext={lightboxPage < pageCount - 1 ? () => setLightboxPage((p) => (p ?? 0) + 1) : undefined}
itemKey={issueKey}
onTagsChanged={onTagsChanged}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly}
/>
)}

72
src/lib/comic-info.ts Normal file
View File

@@ -0,0 +1,72 @@
import AdmZip from 'adm-zip'
import { XMLParser } from 'fast-xml-parser'
import type { ComicInfoData } from '@/types'
const parser = new XMLParser()
function toNumber(val: unknown): number | null {
if (val === undefined || val === null || val === '') return null
const n = Number(val)
return isNaN(n) ? null : n
}
function toString(val: unknown): string | null {
if (val === undefined || val === null || val === '') return null
return String(val)
}
/**
* Parse ComicInfo.xml from inside a CBZ archive.
* Returns null if the archive doesn't contain ComicInfo.xml or parsing fails.
*/
export function parseComicInfo(absoluteCbzPath: string): ComicInfoData | null {
let zip: AdmZip
try {
zip = new AdmZip(absoluteCbzPath)
} catch {
return null
}
// Find ComicInfo.xml (case-insensitive)
const entry = zip.getEntries().find(
(e) => !e.isDirectory && e.entryName.toLowerCase() === 'comicinfo.xml'
)
if (!entry) return null
let xml: string
try {
xml = entry.getData().toString('utf-8')
} catch {
return null
}
let doc: Record<string, unknown>
try {
doc = parser.parse(xml) as Record<string, unknown>
} catch {
return null
}
// The root element can be ComicInfo or ComicInfoXml (varies by source)
const info = (doc.ComicInfo ?? doc.ComicInfoXml ?? doc.comicinfo) as Record<string, unknown> | undefined
if (!info) return null
// Parse tags: comma-separated string
const rawTags = toString(info.Tags)
const tags: string[] = rawTags
? rawTags.split(',').map((t) => t.trim()).filter(Boolean)
: []
return {
title: toString(info.Title),
year: toNumber(info.Year),
month: toNumber(info.Month),
day: toNumber(info.Day),
writer: toString(info.Writer),
translator: toString(info.Translator),
publisher: toString(info.Publisher),
genre: toString(info.Genre),
tags,
web: toString(info.Web),
}
}

203
src/lib/comic-metadata.ts Normal file
View File

@@ -0,0 +1,203 @@
import path from 'path'
import crypto from 'crypto'
import type { Library, ImportedTag, TagMapping } from '@/types'
import { getDb } from './db'
import { resolveLibraryRoot } from './libraries'
import { parseComicInfo } from './comic-info'
// ─── Metadata Import ──────────────────────────────────────────────────────────
/**
* Import ComicInfo.xml metadata for all comic_issue items in a library.
* - Populates media_items fields (title, year, genres, metadata JSON).
* - For each tag: if a mapping exists, assigns the real tag; otherwise creates
* an imported tag entry.
*/
export function importComicMetadata(library: Library): void {
const db = getDb()
const libraryRoot = resolveLibraryRoot(library)
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`
)
.all(library.id) as { item_key: string; file_path: string; metadata: string | null }[]
// 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
`)
const addMediaTag = db.prepare(
'INSERT OR IGNORE INTO media_tags (item_key, tag_id) VALUES (?, ?)'
)
const upsertImportedTag = db.prepare(`
INSERT INTO imported_tags (id, library_id, name) VALUES (@id, @library_id, @name)
ON CONFLICT(library_id, name) DO UPDATE SET name = excluded.name
RETURNING id
`)
const addItemImportedTag = db.prepare(
'INSERT OR IGNORE INTO item_imported_tags (item_key, imported_tag_id) VALUES (?, ?)'
)
let importedCount = 0
db.transaction(() => {
for (const issue of issues) {
const absPath = path.join(libraryRoot, issue.file_path)
const info = parseComicInfo(absPath)
if (!info) continue
// Merge with existing metadata JSON (preserve pageCount, coverUrl, etc.)
const existingMeta = issue.metadata ? JSON.parse(issue.metadata) : {}
const mergedMeta = {
...existingMeta,
writer: info.writer,
publisher: info.publisher,
translator: info.translator,
web: info.web,
month: info.month,
day: info.day,
}
updateItem.run({
item_key: issue.item_key,
title: info.title ?? existingMeta.title ?? null,
year: info.year,
genres: info.genre,
metadata: JSON.stringify(mergedMeta),
})
// Process tags
for (const tagName of info.tags) {
const mappedTagId = mappings.get(tagName)
if (mappedTagId) {
// Mapping exists — assign the real tag
addMediaTag.run(issue.item_key, mappedTagId)
} else {
// No mapping — create imported tag
const importedTagId = crypto.randomUUID()
const row = upsertImportedTag.get({
id: importedTagId,
library_id: library.id,
name: tagName,
}) as { id: string }
addItemImportedTag.run(issue.item_key, row.id)
}
}
importedCount++
}
})()
console.log(`[comic-metadata] Imported metadata for ${importedCount}/${issues.length} issues in "${library.name}"`)
}
// ─── Imported Tags ────────────────────────────────────────────────────────────
export function getImportedTagsForLibrary(libraryId: string): ImportedTag[] {
const db = getDb()
return db
.prepare(
`SELECT it.id, it.library_id as libraryId, it.name,
COUNT(iit.item_key) as itemCount
FROM imported_tags it
LEFT JOIN item_imported_tags iit ON iit.imported_tag_id = it.id
WHERE it.library_id = ?
GROUP BY it.id
ORDER BY it.name`
)
.all(libraryId) as ImportedTag[]
}
// ─── Tag Mappings ─────────────────────────────────────────────────────────────
export function getTagMappingsForLibrary(libraryId: string): TagMapping[] {
const db = getDb()
return db
.prepare(
`SELECT tm.id, tm.library_id as libraryId, tm.imported_tag_name as importedTagName,
tm.tag_id as tagId, t.name as tagName, tc.name as categoryName
FROM tag_mappings tm
JOIN tags t ON t.id = tm.tag_id
JOIN tag_categories tc ON tc.id = t.category_id
WHERE tm.library_id = ?
ORDER BY tm.imported_tag_name`
)
.all(libraryId) as TagMapping[]
}
/**
* Create a tag mapping and apply it: assign the real tag to all items that
* currently have the imported tag, then remove the imported tag entries.
*/
export function createTagMapping(libraryId: string, importedTagName: string, tagId: string): TagMapping {
const db = getDb()
const id = crypto.randomUUID()
return db.transaction(() => {
// Persist the mapping for future scans
db.prepare(`
INSERT INTO tag_mappings (id, library_id, imported_tag_name, tag_id)
VALUES (?, ?, ?, ?)
ON CONFLICT(library_id, imported_tag_name) DO UPDATE SET tag_id = excluded.tag_id
`).run(id, libraryId, importedTagName, tagId)
// Find all items that currently have this imported tag
const importedTag = db
.prepare('SELECT id FROM imported_tags WHERE library_id = ? AND name = ?')
.get(libraryId, importedTagName) as { id: string } | undefined
if (importedTag) {
const itemKeys = db
.prepare('SELECT item_key FROM item_imported_tags WHERE imported_tag_id = ?')
.all(importedTag.id) as { item_key: string }[]
// Assign the real tag to all affected items
const addMediaTag = db.prepare(
'INSERT OR IGNORE INTO media_tags (item_key, tag_id) VALUES (?, ?)'
)
for (const { item_key } of itemKeys) {
addMediaTag.run(item_key, tagId)
}
// Remove the imported tag (cascades to item_imported_tags)
db.prepare('DELETE FROM imported_tags WHERE id = ?').run(importedTag.id)
}
// Fetch the created mapping with joined names
const mapping = db
.prepare(
`SELECT tm.id, tm.library_id as libraryId, tm.imported_tag_name as importedTagName,
tm.tag_id as tagId, t.name as tagName, tc.name as categoryName
FROM tag_mappings tm
JOIN tags t ON t.id = tm.tag_id
JOIN tag_categories tc ON tc.id = t.category_id
WHERE tm.library_id = ? AND tm.imported_tag_name = ?`
)
.get(libraryId, importedTagName) as TagMapping
return mapping
})()
}
export function deleteTagMapping(id: string): void {
const db = getDb()
const result = db.prepare('DELETE FROM tag_mappings WHERE id = ?').run(id)
if (result.changes === 0) throw new Error('Mapping not found')
}

View File

@@ -109,6 +109,7 @@ function initDb(db: Database.Database): void {
migrateLibraryPermissionsAccessLevel(db)
migrateLibrariesAddComics(db)
migrateComicItemTypes(db)
migrateImportedTags(db)
seedAppSettings(db)
}
@@ -421,3 +422,28 @@ function migrateAiJobs(db: Database.Database): void {
db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT')
}
}
function migrateImportedTags(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS imported_tags (
id TEXT PRIMARY KEY,
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
name TEXT NOT NULL,
UNIQUE(library_id, name)
);
CREATE TABLE IF NOT EXISTS item_imported_tags (
item_key TEXT NOT NULL,
imported_tag_id TEXT NOT NULL REFERENCES imported_tags(id) ON DELETE CASCADE,
PRIMARY KEY (item_key, imported_tag_id)
);
CREATE TABLE IF NOT EXISTS tag_mappings (
id TEXT PRIMARY KEY,
library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
imported_tag_name TEXT NOT NULL,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
UNIQUE(library_id, imported_tag_name)
);
`)
}

View File

@@ -12,6 +12,7 @@ import { getThumbnailPath, getCbzThumbnailPath } from './thumbnails'
import { computeFingerprint } from './fingerprint'
import { reKeyMediaItem } from './tags'
import { runAiTagging } from './ai-tagger'
import { importComicMetadata } from './comic-metadata'
let scanRunning = false
@@ -651,6 +652,13 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
}
console.log(`[scanner] comics: indexed ${items.filter((i) => 'issues' in i).length} series, ${issueCount} issues`)
// Import ComicInfo.xml metadata (title, year, genres, tags)
try {
importComicMetadata(library)
} catch (err) {
console.error(`[scanner] Error importing comic metadata for "${library.name}":`, err)
}
}
// ---------------------------------------------------------------------------

View File

@@ -146,3 +146,32 @@ export interface UserSettings {
tvLoop: boolean
tvMuted: boolean
}
export interface ComicInfoData {
title: string | null
year: number | null
month: number | null
day: number | null
writer: string | null
translator: string | null
publisher: string | null
genre: string | null
tags: string[]
web: string | null
}
export interface ImportedTag {
id: string
libraryId: string
name: string
itemCount: number
}
export interface TagMapping {
id: string
libraryId: string
importedTagName: string
tagId: string
tagName?: string
categoryName?: string
}