Files

169 lines
9.9 KiB
JavaScript

// Librería: listas propias del usuario, recomendaciones según sus géneros y descarga anticipada
// de los capítulos siguientes a lo que se está leyendo.
const PREFETCH_DEFAULT = { n: 2, hd: false }
const DEFAULT_LISTS = ['Favoritos', 'Pendientes', 'Abandonados']
const REC_TTL = 6 * 3600e3
import { genreName } from './features.js'
const norm = s => String(s).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats, searchManga, mdCategories, net }) {
db.exec(`
CREATE TABLE IF NOT EXISTS lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS list_items (
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
title TEXT NOT NULL,
cover TEXT,
added_at INTEGER NOT NULL,
PRIMARY KEY (list_id, manga_id)
);
`)
if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'prefetch')) db.exec('ALTER TABLE users ADD COLUMN prefetch TEXT')
// ---------- Listas ----------
function listsOf(userId) {
let rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId)
if (!rows.length && !db.prepare('SELECT 1 FROM kv WHERE k = ?').get(`lists_init_${userId}`)) {
const ins = db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)')
for (const n of DEFAULT_LISTS) ins.run(userId, n, Date.now())
db.prepare('INSERT INTO kv (k, v) VALUES (?, ?)').run(`lists_init_${userId}`, '1')
rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId)
}
const count = db.prepare('SELECT COUNT(*) n FROM list_items WHERE list_id = ?')
return rows.map(l => ({ ...l, count: count.get(l.id).n }))
}
const ownList = (userId, id) => {
const l = db.prepare('SELECT * FROM lists WHERE id = ? AND user_id = ?').get(id, userId)
if (!l) throw new HttpError(404, 'Lista no encontrada')
return l
}
// Listas que contienen un manga (para la ficha).
const listsWith = (userId, mangaId) => db.prepare(`SELECT l.id FROM lists l JOIN list_items i ON i.list_id = l.id
WHERE l.user_id = ? AND i.manga_id = ?`).all(userId, mangaId).map(r => r.id)
// ---------- Descarga anticipada ----------
function prefetchCfg(userId) {
try { return { ...PREFETCH_DEFAULT, ...JSON.parse(db.prepare('SELECT prefetch FROM users WHERE id = ?').get(userId)?.prefetch || '{}') } } catch { return { ...PREFETCH_DEFAULT } }
}
const recent = new Map() // `${manga}|${chapter}` -> ts, para no repetir en cada guardado de progreso
// Al pasar la mitad de un capítulo, encola los N siguientes (y, si se pidió, su versión HD).
function onProgress(userId, b, page, total) {
const cfg = prefetchCfg(userId)
if (!cfg.n || !total || page < Math.floor(total / 2) || !net.online()) return
const key = `${b.mangaId}|${b.chapterId}|${cfg.n}|${cfg.hd}`
if (Date.now() - (recent.get(key) || 0) < 30 * 60e3) return
recent.set(key, Date.now())
if (recent.size > 2000) recent.delete(recent.keys().next().value)
const lang = /^(lc|mi)~/.test(b.mangaId) ? 'es' : b.lang
fetchMangaFull(b.mangaId, lang).then(({ manga, chapters }) => {
const i = chapters.findIndex(c => c.id === b.chapterId)
if (i < 0) return
const next = chapters.slice(i + 1, i + 1 + cfg.n).map(c => c.id)
if (!next.length) return
const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(b.mangaId).map(r => r.chapter_id))
const missing = next.filter(id => !have.has(id))
if (missing.length) {
enqueueDownload(b.mangaId, manga.title, lang, missing)
linkDownload(userId, b.mangaId)
console.log(`Descarga anticipada: ${manga.title} → ${missing.length} cap(s).`)
}
if (cfg.hd && upscale.available()) upscale.request(next)
}).catch(e => console.warn('Descarga anticipada:', e.message))
}
// ---------- Recomendaciones ----------
const recCache = new Map() // userId -> { at, data }
// Populares de MangaDex (por seguidores) en tus 3 géneros principales, con capítulos en tu idioma.
// LeerCapitulo no ordena sus géneros por popularidad, por eso la fuente es MangaDex.
async function recommendations(userId, refresh) {
const hit = recCache.get(userId)
if (hit && !refresh && Date.now() - hit.at < REC_TTL) return hit.data
if (!net.online()) return hit?.data ?? { genres: [], items: [] }
const lang = db.prepare('SELECT lang FROM users WHERE id = ?').get(userId)?.lang || 'es-la'
const genres = stats(userId).genres.slice(0, 5)
const cats = (await mdCategories().catch(() => [])).filter(c => c.group === 'Géneros' || c.group === 'Temáticas')
const byName = new Map(cats.map(c => [norm(genreName(c.name) || c.name), c]))
const picked = genres.map(g => ({ g, cat: byName.get(norm(g.name)) })).filter(x => x.cat).slice(0, 3)
const mine = db.prepare(`SELECT manga_id, manga_title title FROM progress WHERE user_id = ? UNION SELECT manga_id, title FROM follows WHERE user_id = ?
UNION SELECT i.manga_id, i.title FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?`).all(userId, userId, userId)
const knownIds = new Set(mine.map(r => r.manga_id))
const knownTitles = new Set(mine.map(r => norm(r.title).replace(/[^a-z0-9]/g, '')))
// Puntuación: aparecer en varios de tus géneros y salir arriba en cada lista.
const score = new Map()
const page = refresh ? 1 + Math.floor(Math.random() * 3) : 1 // "otras recomendaciones": otra página
const results = await Promise.all(picked.map(({ cat }) => searchManga('', lang, 'popular', cat.id, page).catch(() => [])))
results.forEach((list, gi) => list.forEach((m, rank) => {
if (!m.id || knownIds.has(m.id) || knownTitles.has(norm(m.title).replace(/[^a-z0-9]/g, ''))) return
const s = score.get(m.id) || { m, pts: 0, why: [] }
s.pts += (picked.length - gi) * 2 + Math.max(0, 30 - rank) / 10 + 3
s.why.push(picked[gi].g.name)
score.set(m.id, s)
}))
const items = [...score.values()].sort((a, b) => b.pts - a.pts).slice(0, 18)
.map(({ m, why }) => ({ ...m, sub: `Por ${why.slice(0, 2).join(' y ')}` }))
const data = { genres: picked.map(x => ({ name: x.g.name, id: x.cat.id, source: 'md' })), items }
if (items.length) recCache.set(userId, { at: Date.now(), data }) // vacío (fuente caída): no guardarlo 6 h
return data
}
// ---------- Rutas ----------
async function route(p, method, q, req, res, user) {
let m
if (p === '/api/lists' && method === 'GET') return json(res, 200, listsOf(user.id)), true
if (p === '/api/lists' && method === 'POST') {
const b = await readBody(req)
const name = String(b.name || '').trim().slice(0, 60)
if (!name) throw new HttpError(400, 'Ponle un nombre a la lista')
if (db.prepare('SELECT COUNT(*) n FROM lists WHERE user_id = ?').get(user.id).n >= 50) throw new HttpError(400, 'Demasiadas listas')
const id = Number(db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)').run(user.id, name, Date.now()).lastInsertRowid)
return json(res, 200, { id, name, count: 0 }), true
}
if ((m = /^\/api\/lists\/(\d+)$/.exec(p))) {
const l = ownList(user.id, Number(m[1]))
if (method === 'GET') {
const items = db.prepare('SELECT manga_id, title, cover, added_at FROM list_items WHERE list_id = ? ORDER BY added_at DESC').all(l.id)
return json(res, 200, { id: l.id, name: l.name, items }), true
}
if (method === 'PATCH') {
const name = String((await readBody(req)).name || '').trim().slice(0, 60)
if (!name) throw new HttpError(400, 'Ponle un nombre a la lista')
db.prepare('UPDATE lists SET name = ? WHERE id = ?').run(name, l.id)
return json(res, 200, { ok: true }), true
}
if (method === 'DELETE') { db.prepare('DELETE FROM lists WHERE id = ?').run(l.id); return json(res, 200, { ok: true }), true }
}
if ((m = /^\/api\/lists\/(\d+)\/items$/.exec(p)) && method === 'POST') {
const l = ownList(user.id, Number(m[1]))
const b = await readBody(req)
if (!MANGA_ID.test(b.mangaId || '')) throw new HttpError(400, 'Manga inválido')
const cover = String(b.cover || '').slice(0, 1000)
db.prepare(`INSERT INTO list_items (list_id, manga_id, title, cover, added_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (list_id, manga_id) DO UPDATE SET title = excluded.title, cover = excluded.cover`)
.run(l.id, b.mangaId, String(b.title || '').slice(0, 300) || b.mangaId, cover.startsWith('/api/') ? cover : null, Date.now())
return json(res, 200, { lists: listsWith(user.id, b.mangaId) }), true
}
if ((m = /^\/api\/lists\/(\d+)\/items\/([^/]+)$/.exec(p)) && method === 'DELETE') {
const l = ownList(user.id, Number(m[1]))
const mangaId = decodeURIComponent(m[2])
db.prepare('DELETE FROM list_items WHERE list_id = ? AND manga_id = ?').run(l.id, mangaId)
return json(res, 200, { lists: listsWith(user.id, mangaId) }), true
}
if (p === '/api/prefetch' && method === 'GET') return json(res, 200, prefetchCfg(user.id)), true
if (p === '/api/prefetch' && method === 'POST') {
const b = await readBody(req)
const n = Math.min(10, Math.max(0, Number(b.n) | 0))
db.prepare('UPDATE users SET prefetch = ? WHERE id = ?').run(JSON.stringify({ n, hd: !!b.hd }), user.id)
return json(res, 200, prefetchCfg(user.id)), true
}
if (p === '/api/recommendations' && method === 'GET') return json(res, 200, await recommendations(user.id, q.get('refresh') === '1')), true
return false
}
return { route, onProgress, listsOf, listsWith }
}