// Créditos de los traductores: cada grupo repite casi la misma página en sus capítulos, y las de historia no se repiten. // El navegador resume la página 0 y las 2 últimas de cada capítulo (el servidor no sabe decodificar imágenes) y aquí se // comparan con las del resto de la obra. Solo se ocultan al leer por tomos, y nunca se borra nada. const SIDE = 16 const SIG_BYTES = SIDE * SIDE const SAME = 0.03 // diferencia media: los créditos quedan por debajo del 2% y la historia muy por encima const SAME_SHAPE = 0.03 const MIN_PAGES = 5 const STRIP = 0.45 // tira larga: los créditos vienen pegados a la historia y no se puede quitar uno sin la otra const PLAIN = 0.03 // página casi lisa (en blanco, negra): no son créditos, aunque se repitan const decode = s => Buffer.from(String(s || ''), 'base64') const spread = b => { const mean = b.reduce((a, x) => a + x, 0) / b.length return Math.sqrt(b.reduce((a, x) => a + (x - mean) ** 2, 0) / b.length) / 255 } const difference = (a, b) => { let sum = 0 for (let i = 0; i < SIG_BYTES; i++) sum += Math.abs(a[i] - b[i]) return sum / SIG_BYTES / 255 } /** Qué páginas repetidas tiene cada capítulo pedido. rows: [{ id, n, pages: { índice: { s, ar } } }], de toda la obra. */ export function repeatedPages(rows, ids) { const all = rows.flatMap(r => Object.entries(r.pages).map(([i, p]) => ({ id: r.id, i: Number(i), ar: p.ar, sig: decode(p.s) }))) .filter(p => p.sig.length === SIG_BYTES) const out = {} for (const r of rows) { if (!ids.includes(r.id) || r.n < MIN_PAGES) continue const hidden = [] for (const p of all) { if (p.id !== r.id || p.ar < STRIP || spread(p.sig) < PLAIN) continue if (![0, r.n - 2, r.n - 1].includes(p.i)) continue if (all.some(o => o.id !== r.id && Math.abs(o.ar - p.ar) < SAME_SHAPE && difference(o.sig, p.sig) < SAME)) hidden.push(p.i) } if (hidden.length) out[r.id] = hidden.sort((a, b) => a - b) } return out } export function setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID }) { db.exec(` CREATE TABLE IF NOT EXISTS page_sigs ( manga_id TEXT NOT NULL, chapter_id TEXT NOT NULL, n INTEGER NOT NULL, pages TEXT NOT NULL, PRIMARY KEY (manga_id, chapter_id) ); CREATE TABLE IF NOT EXISTS hide_credits ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, manga_id TEXT NOT NULL, PRIMARY KEY (user_id, manga_id) );`) const enabled = (userId, mangaId) => !!db.prepare('SELECT 1 FROM hide_credits WHERE user_id = ? AND manga_id = ?').get(userId, mangaId) function validPages(pages, n) { const entries = Object.entries(pages || {}) if (!entries.length || entries.length > 3) return false return entries.every(([i, p]) => [0, n - 2, n - 1].includes(Number(i)) && typeof p?.ar === 'number' && p.ar > 0 && decode(p.s).length === SIG_BYTES) } async function route(p, method, req, res, user) { let m if ((m = /^\/api\/credits\/([^/]+)\/option$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { const { on } = await readBody(req) if (on) db.prepare('INSERT OR IGNORE INTO hide_credits (user_id, manga_id) VALUES (?, ?)').run(user.id, m[1]) else db.prepare('DELETE FROM hide_credits WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]) json(res, 200, { hideCredits: !!on }) return true } if ((m = /^\/api\/credits\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { const body = await readBody(req) const wanted = Array.isArray(body.chapters) ? body.chapters.slice(0, 60) : [] if (!wanted.every(c => CHAPTER_ID.test(c?.id || '') && Number.isInteger(c?.n) && c.n > 0 && c.n < 100000)) throw new HttpError(400, 'Capítulos inválidos') const save = db.prepare('INSERT OR REPLACE INTO page_sigs (manga_id, chapter_id, n, pages) VALUES (?, ?, ?, ?)') for (const c of wanted) { const pages = body.sigs?.[c.id] if (pages && validPages(pages, c.n)) save.run(m[1], c.id, c.n, JSON.stringify(pages)) } const rows = db.prepare('SELECT chapter_id AS id, n, pages FROM page_sigs WHERE manga_id = ?').all(m[1]).map(r => ({ id: r.id, n: r.n, pages: JSON.parse(r.pages) })) const known = new Map(rows.map(r => [r.id, r.n])) // Un capítulo vuelto a subir puede tener otras páginas: su resumen ya no vale. const missing = wanted.filter(c => known.get(c.id) !== c.n).map(c => c.id) const current = rows.filter(r => wanted.every(c => c.id !== r.id || c.n === r.n)) json(res, 200, { missing, hidden: repeatedPages(current, wanted.map(c => c.id).filter(id => !missing.includes(id))) }) return true } return false } return { route, enabled } }