Files

93 lines
4.6 KiB
JavaScript

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