diff --git a/src/app/api/imported-tags/route.ts b/src/app/api/imported-tags/route.ts new file mode 100644 index 0000000..87b833d --- /dev/null +++ b/src/app/api/imported-tags/route.ts @@ -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) +} diff --git a/src/app/api/libraries/[id]/import-metadata/route.ts b/src/app/api/libraries/[id]/import-metadata/route.ts new file mode 100644 index 0000000..47d8784 --- /dev/null +++ b/src/app/api/libraries/[id]/import-metadata/route.ts @@ -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 }) +} diff --git a/src/app/api/tag-mappings/[id]/route.ts b/src/app/api/tag-mappings/[id]/route.ts new file mode 100644 index 0000000..230e699 --- /dev/null +++ b/src/app/api/tag-mappings/[id]/route.ts @@ -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 }) + } +} diff --git a/src/app/api/tag-mappings/route.ts b/src/app/api/tag-mappings/route.ts new file mode 100644 index 0000000..38b37cd --- /dev/null +++ b/src/app/api/tag-mappings/route.ts @@ -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 }) + } +} diff --git a/src/app/manage/page.tsx b/src/app/manage/page.tsx index f6d26ba..fa2508a 100644 --- a/src/app/manage/page.tsx +++ b/src/app/manage/page.tsx @@ -107,6 +107,7 @@ 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 cancelRef = useRef | null>(null) const fileInputRef = useRef(null) @@ -209,6 +210,30 @@ function LibraryRow({ {/* Actions */}
+ {library.type === 'comics' && ( + + )} {library.coverExt && ( + + + + ) : ( + <> + {/* Inline create: category picker + name input + create & map button */} + + + 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 + /> + + + + + + )} +
+ {error && ( +

+ {error} +

+ )} + + ) +} + +// ─── Mapping Row ────────────────────────────────────────────────────────────── + +function MappingRow({ mapping, onDeleted }: { mapping: TagMapping; onDeleted: () => void }) { + const [confirming, setConfirming] = useState(false) + const [deleting, setDeleting] = useState(false) + const cancelRef = useRef | 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 ( +
+ + {mapping.importedTagName} + + + + {mapping.categoryName}: {mapping.tagName} + +
+ {confirming && ( + + )} + +
+ ) +} + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
+
{children}
+
+
+ ) +} + +function LoadingRows() { + return ( +
+ {[70, 50, 85].map((w) => ( +
+
+
+ ))} +
+ ) +} diff --git a/src/app/manage/tags/page.tsx b/src/app/manage/tags/page.tsx index 2f23f7b..875a34a 100644 --- a/src/app/manage/tags/page.tsx +++ b/src/app/manage/tags/page.tsx @@ -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() {
+ +
) } @@ -480,6 +482,80 @@ function AddCategoryForm({ onAdded }: { onAdded: () => void }) { ) } +// ─── Imported Tag Mappings Section ──────────────────────────────────────────── + +function ImportedTagMappingsSection() { + const [libraries, setLibraries] = useState([]) + const [tagCounts, setTagCounts] = useState>({}) + 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 = {} + 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 ( +
+ +
+ ) + } + + if (libraries.length === 0) { + return ( +
+

+ No comic libraries configured. Add a comic library to import tags from ComicInfo.xml files. +

+
+ ) + } + + return ( +
+
+ {libraries.map((lib) => ( + + ))} +
+
+ ) +} + // ─── Shared helpers ─────────────────────────────────────────────────────────── function Section({ title, children }: { title: string; children: React.ReactNode }) { diff --git a/src/lib/comic-info.ts b/src/lib/comic-info.ts new file mode 100644 index 0000000..e2f358f --- /dev/null +++ b/src/lib/comic-info.ts @@ -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 + try { + doc = parser.parse(xml) as Record + } catch { + return null + } + + // The root element can be ComicInfo or ComicInfoXml (varies by source) + const info = (doc.ComicInfo ?? doc.ComicInfoXml ?? doc.comicinfo) as Record | 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), + } +} diff --git a/src/lib/comic-metadata.ts b/src/lib/comic-metadata.ts new file mode 100644 index 0000000..8aa3f1a --- /dev/null +++ b/src/lib/comic-metadata.ts @@ -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') +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 195b0f8..ca4f516 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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) + ); + `) +} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 810742b..71c411c 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -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 } 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) + } } // --------------------------------------------------------------------------- diff --git a/src/types/index.ts b/src/types/index.ts index 3b1a9b5..7ea3bb4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 +}