217 lines
8.1 KiB
TypeScript
217 lines
8.1 KiB
TypeScript
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<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
|
|
|
|
// 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<void>((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')
|
|
}
|