add tag imports

This commit is contained in:
Garret Patti
2026-04-19 21:41:34 -04:00
parent 95bcaf53be
commit 0842769125
12 changed files with 1002 additions and 1 deletions

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)
}
}
// ---------------------------------------------------------------------------