import path from 'path' import crypto from 'crypto' import type { Library, ImportedTag, TagMapping } from '@/types' import { getDb } from './db' import { resolveLibraryRoot } from './libraries' import { parseComicInfoAsync } from './comic-info' import { mapConcurrent } from './zip-utils' // ─── 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 async function importComicMetadata(library: Library): Promise { 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 // Process in batches: async file reads (10 concurrent) followed by batch DB writes, // with an event-loop yield between batches to keep the app responsive. const BATCH_SIZE = 50 for (let i = 0; i < issues.length; i += BATCH_SIZE) { const batch = issues.slice(i, i + BATCH_SIZE) // Async: read ComicInfo.xml from each archive concurrently (10 at a time). // Uses async ZIP central-directory reader — no full-file reads. const infos = await mapConcurrent(batch, 10, (issue) => parseComicInfoAsync(path.join(libraryRoot, issue.file_path)) ) // Sync: write this batch to the DB in one transaction. db.transaction(() => { for (let j = 0; j < batch.length; j++) { const issue = batch[j] const info = infos[j] if (!info) continue 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), }) for (const tagName of info.tags) { const mappedTagId = mappings.get(tagName) if (mappedTagId) { addMediaTag.run(issue.item_key, mappedTagId) } else { 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++ } })() await new Promise((r) => setImmediate(r)) } 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') }