import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFile } from 'node:child_process'; import { info, warn } from './logger.mjs'; const DEFAULT_BIN = process.env.CALIBRE_FETCH_BIN || 'fetch-ebook-metadata'; const DEFAULT_DEBUG_BIN = process.env.CALIBRE_DEBUG_BIN || 'calibre-debug'; const DEFAULT_TIMEOUT_MS = 75_000; const MAX_LOG_CHARS = 4000; // Machine-readable identification pass. Calibre's fetch-ebook-metadata CLI // prints useful bibliographic metadata, but its human-readable/verbose output // is not stable enough to parse. This fixed calibre-debug command asks // Calibre's own identify pipeline for the best matches and emits only a JSON // marker that kovi can consume. User-controlled metadata is supplied via // environment variables and is never interpolated into Python source. const IDENTIFY_JSON_CODE = [ 'import os, json', 'from io import BytesIO', 'from threading import Event', 'from calibre.ebooks.metadata import string_to_authors', 'from calibre.ebooks.metadata.sources.base import create_log', 'from calibre.ebooks.metadata.sources.identify import identify', 'from calibre.ebooks.metadata.sources.update import patch_plugins', 'patch_plugins()', "title=os.environ.get('KOVI_CALIBRE_TITLE') or None", "author=os.environ.get('KOVI_CALIBRE_AUTHORS') or ''", 'authors=string_to_authors(author) if author else []', 'identifiers={}', "isbn=os.environ.get('KOVI_CALIBRE_ISBN') or ''", "identifiers.update({'isbn': isbn}) if isbn else None", 'buf=BytesIO(); log=create_log(buf)', "results=identify(log,Event(),title=title,authors=authors,identifiers=identifiers,timeout=int(os.environ.get('KOVI_CALIBRE_TIMEOUT','45')))", "payload=[{'title':getattr(m,'title','') or '', 'authors':list(getattr(m,'authors',[]) or []), 'identifiers':m.get_identifiers() if hasattr(m,'get_identifiers') else {}, 'languages':list(getattr(m,'languages',[]) or [])} for m in results[:5]]", "print('KOVI_METADATA_JSON='+json.dumps(payload,ensure_ascii=False,separators=(',',':')))", ].join(';'); // Calibre's fetch-ebook-metadata CLI only reaches the cover phase after the // identify phase succeeds. Google Images is a cover-only source and is disabled // by default in Calibre, so hard cases with translated titles can otherwise // never reach it. This fixed command runs Calibre's own cover downloader in a // separate calibre-debug process, explicitly enabling Google Images. Metadata // enters only through environment variables, never interpolated Python/source. const DIRECT_COVER_CODE = [ 'import os', 'from io import BytesIO', 'from calibre.customize.ui import enable_plugin', "enable_plugin('Google Images')", 'from calibre.ebooks.metadata import string_to_authors', 'from calibre.ebooks.metadata.sources.base import create_log', 'from calibre.ebooks.metadata.sources.covers import download_cover', "title=os.environ.get('KOVI_CALIBRE_TITLE') or None", "author=os.environ.get('KOVI_CALIBRE_AUTHORS') or ''", 'authors=string_to_authors(author) if author else []', 'identifiers={}', "isbn=os.environ.get('KOVI_CALIBRE_ISBN') or ''", "identifiers.update({'isbn': isbn}) if isbn else None", 'buf=BytesIO(); log=create_log(buf)', "cover=download_cover(log,title=title,authors=authors,identifiers=identifiers,timeout=int(os.environ.get('KOVI_CALIBRE_TIMEOUT','60')))", "out=os.environ['KOVI_CALIBRE_OUTPUT']", "open(out,'wb').write(cover[-1]) if cover else None", "print('provider='+cover[0].name if cover else 'no-cover')", "print(buf.getvalue().decode('utf-8','replace') if hasattr(buf.getvalue(),'decode') else str(buf.getvalue()))", ].join(';'); function enabledByEnv() { const raw = String(process.env.CALIBRE_COVER_RESOLVER ?? 'auto').trim().toLowerCase(); return !['0', 'false', 'no', 'off', 'disabled'].includes(raw); } function cleanAuthor(value='') { const s = String(value || '').trim(); if (!s || /^(n\/?a|unknown|sconosciuto)$/i.test(s)) return ''; return s; } function stripQtNoise(value='') { return String(value || '') .replace(/^.*(?:QRhiGles2|QVulkanInstance|WebEngineContext|Unable to detect GPU vendor|createPlatformVulkanInstance).*$/gmi, '') .replace(/\n{3,}/g, '\n\n'); } function trimLog(value='') { const s = stripQtNoise(value).replace(/\r/g, '').trim(); if (!s) return ''; return s.length > MAX_LOG_CHARS ? `${s.slice(0, MAX_LOG_CHARS)}…` : s; } function parseMetadataJson(value='') { const text=String(value||''); const line=text.split(/\r?\n/).find(x=>x.startsWith('KOVI_METADATA_JSON=')); if(!line) return []; try { const parsed=JSON.parse(line.slice('KOVI_METADATA_JSON='.length)); if(!Array.isArray(parsed)) return []; return parsed.slice(0,5).map(x=>({ title:String(x?.title||'').slice(0,1000), authors:Array.isArray(x?.authors)?x.authors.map(a=>String(a).slice(0,500)).slice(0,10):[], identifiers:x?.identifiers && typeof x.identifiers==='object' ? Object.fromEntries(Object.entries(x.identifiers).slice(0,20).map(([k,v])=>[String(k).slice(0,80),String(v).slice(0,500)])) : {}, languages:Array.isArray(x?.languages)?x.languages.map(v=>String(v).slice(0,40)).slice(0,10):[], })); } catch { return []; } } function execFilePromise(file, args, options, impl = execFile) { return new Promise((resolve, reject) => { impl(file, args, options, (error, stdout, stderr) => { if (error) { error.stdout = stdout; error.stderr = stderr; reject(error); } else resolve({ stdout, stderr }); }); }); } function normalizeTitle(value='') { return String(value).toLocaleLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[^\p{L}\p{N}]+/gu,' ').trim(); } export function calibreTitleVariants(title='') { const raw=String(title||'').trim(); const out=[]; const push=(value)=>{ const v=String(value||'').trim(); if(v && !out.some(x=>normalizeTitle(x)===normalizeTitle(v))) out.push(v); }; push(raw); push(raw.replace(/\s*[\[(].*?[\])]\s*$/,'').trim()); const dot=raw.match(/^.{2,32}\.\s+(.{3,})$/); if(dot) push(dot[1]); const colon=raw.match(/^.{2,32}:\s+(.{3,})$/); if(colon) push(colon[1]); return out.slice(0,3); } export function calibreArgsForBook(book, coverPath, {includeAuthor=true, includeIsbn=true, titleOverride=null}={}) { const args = []; const title=titleOverride ?? book.title; if (title) args.push('--title', String(title).trim()); args.push('--cover', coverPath, '--timeout', '30'); const author = cleanAuthor(book.authors); if (includeAuthor && author) args.push('--authors', author); if (includeIsbn && book.isbn) args.push('--isbn', String(book.isbn)); args.push('--verbose'); return args; } export function calibreQueryPlans(book) { const plans=[]; const push=(name,opts)=>{ const key=JSON.stringify(opts); if(!plans.some(p=>JSON.stringify(p.opts)===key)) plans.push({name,opts}); }; if(book.isbn) push('isbn-title-author',{includeAuthor:true,includeIsbn:true,titleOverride:book.title}); for(const [i,title] of calibreTitleVariants(book.title).entries()) { const label=i===0?'title':`title-variant-${i}`; push(`${label}-author`,{includeAuthor:true,includeIsbn:false,titleOverride:title}); push(`${label}-only`,{includeAuthor:false,includeIsbn:false,titleOverride:title}); } return plans.slice(0,5); } function directCoverPlans(book) { const author=cleanAuthor(book.authors); const plans=[]; for(const [i,title] of calibreTitleVariants(book.title).entries()) { const label=i===0?'direct-title':`direct-title-variant-${i}`; if(author) plans.push({name:`${label}-author`,title,author}); plans.push({name:label,title,author:''}); } return plans.slice(0,5); } function calibreEnv(baseEnv, configDir, tmpDir, extra={}) { return { ...baseEnv, CALIBRE_CONFIG_DIRECTORY: configDir, CALIBRE_TEMP_DIR: path.join(tmpDir,'tmp'), CALIBRE_CACHE_DIRECTORY: path.join(tmpDir,'cache'), QT_QPA_PLATFORM: baseEnv.QT_QPA_PLATFORM || 'offscreen', QT_OPENGL: baseEnv.QT_OPENGL || 'software', QT_QUICK_BACKEND: baseEnv.QT_QUICK_BACKEND || 'software', LIBGL_ALWAYS_SOFTWARE: baseEnv.LIBGL_ALWAYS_SOFTWARE || '1', QTWEBENGINE_DISABLE_SANDBOX: baseEnv.QTWEBENGINE_DISABLE_SANDBOX || '1', QTWEBENGINE_CHROMIUM_FLAGS: baseEnv.QTWEBENGINE_CHROMIUM_FLAGS || '--disable-gpu --disable-dev-shm-usage --no-sandbox', ...extra, }; } async function runDirectCoverStage(book,{tmpDir,configDir,execFileImpl,debugBin,timeoutMs,env}) { const plans=directCoverPlans(book); info('cover.calibre.direct.start','Running Calibre cover-only sources, including Google Images.',{ bookId:book.id,title:book.title,plans:plans.map(p=>p.name), }); let lastDetail=''; for(let i=0;iObject.keys(x.identifiers||{})), }); return candidates; } catch(e) { if(e?.code==='ENOENT') return []; warn('cover.calibre.metadata.error','Calibre metadata identification failed after cover lookup.',{bookId:book.id,title:book.title,error:trimLog(e?.stderr||e?.stdout||e?.message)}); return []; } } /** * Ask Calibre's own metadata-source framework to find the best cover. First * use fetch-ebook-metadata (Calibre's identify -> cover path). If identify * cannot find the book, run Calibre's cover downloader directly so cover-only * sources such as Google Images still get a chance. */ export async function fetchCoverWithCalibre(book, { dataDir, execFileImpl = execFile, bin = DEFAULT_BIN, debugBin = DEFAULT_DEBUG_BIN, timeoutMs = DEFAULT_TIMEOUT_MS, env = process.env, } = {}) { if (!enabledByEnv()) return { status:'unavailable', reason:'disabled' }; if (!book?.title && !book?.isbn) return { status:'none', reason:'insufficient-metadata' }; const base = path.join(dataDir || os.tmpdir(), 'uploads'); fs.mkdirSync(base, { recursive:true }); const tmpDir = fs.mkdtempSync(path.join(base, 'calibre-cover-')); const configDir = path.join(tmpDir, 'config'); fs.mkdirSync(configDir, { recursive:true }); fs.mkdirSync(path.join(tmpDir,'tmp'),{recursive:true}); fs.mkdirSync(path.join(tmpDir,'cache'),{recursive:true}); const started = Date.now(); const plans = calibreQueryPlans(book); info('cover.calibre.start', 'Asking Calibre metadata sources for a cover.', { bookId:book.id, title:book.title, authors:book.authors, isbn:book.isbn || undefined, plans:plans.map(p=>p.name), }); try { let lastDetail=''; for (let i=0;i