// Seguimiento de capítulos nuevos (+ notificaciones ntfy/Telegram y autodescarga), // preferencias del lector por manga y estadísticas de lectura. const CHECK_HOURS = Number(process.env.CHECK_INTERVAL_HOURS || 3) const PUBLIC_URL = (process.env.PUBLIC_URL || 'http://192.168.1.141:3352').replace(/\/+$/, '') // Día local (TZ del contenedor) en formato YYYY-MM-DD. export const dayOf = ts => new Date(ts).toLocaleDateString('sv-SE') const sleep = ms => new Promise(r => setTimeout(r, ms)) const chapterKey = c => c.num ?? c.id // Nombres de género unificados para las estadísticas (LeerCapitulo mezcla inglés y español). const GENRE_ES = { action: 'Acción', accion: 'Acción', adventure: 'Aventura', comedy: 'Comedia', drama: 'Drama', fantasy: 'Fantasía', fantasia: 'Fantasía', romance: 'Romance', horror: 'Terror', terror: 'Terror', mystery: 'Misterio', misterio: 'Misterio', psychological: 'Psicológico', psicologico: 'Psicológico', 'sci-fi': 'Ciencia ficción', 'science fiction': 'Ciencia ficción', 'ciencia ficcion': 'Ciencia ficción', 'slice of life': 'Recuentos de la vida', 'recuentos de la vida': 'Recuentos de la vida', sports: 'Deportes', deportes: 'Deportes', supernatural: 'Sobrenatural', sobrenatural: 'Sobrenatural', thriller: 'Thriller', tragedy: 'Tragedia', tragedia: 'Tragedia', historical: 'Histórico', historico: 'Histórico', mecha: 'Mecha', 'martial arts': 'Artes marciales', 'artes marciales': 'Artes marciales', 'school life': 'Vida escolar', 'vida escolar': 'Vida escolar', shounen: 'Shounen', shoujo: 'Shoujo', seinen: 'Seinen', josei: 'Josei', isekai: 'Isekai', magic: 'Magia', magia: 'Magia', harem: 'Harem', ecchi: 'Ecchi', music: 'Música', musica: 'Música', military: 'Militar', militar: 'Militar', medical: 'Médico', crime: 'Crimen', crimen: 'Crimen', philosophical: 'Filosófico', reincarnation: 'Reencarnación', reencarnacion: 'Reencarnación', monsters: 'Monstruos', monstruos: 'Monstruos', vampires: 'Vampiros', vampiros: 'Vampiros', zombies: 'Zombis', zombis: 'Zombis', 'video games': 'Videojuegos', videojuegos: 'Videojuegos', 'time travel': 'Viajes en el tiempo', survival: 'Supervivencia', supervivencia: 'Supervivencia', cooking: 'Cocina', cocina: 'Cocina', 'office workers': 'Oficinistas', villainess: 'Villana', demons: 'Demonios', demonios: 'Demonios', gore: 'Gore', superhero: 'Superhéroes', 'virtual reality': 'Realidad virtual', 'post-apocalyptic': 'Postapocalíptico', apocaliptico: 'Postapocalíptico', animals: 'Animales', 'gender bender': 'Gender bender', 'boys\' love': 'Boys\' love', yaoi: 'Boys\' love', "girls' love": "Girls' love", yuri: "Girls' love", wuxia: 'Wuxia', aliens: 'Alienígenas', ninja: 'Ninjas', samurai: 'Samuráis', police: 'Policial', mafia: 'Mafia', delinquents: 'Delincuentes', 'reverse harem': 'Harem inverso', 'traditional games': 'Juegos tradicionales', ghosts: 'Fantasmas', 'monster girls': 'Monster girls', } // Etiquetas de formato que no son géneros. const NOT_GENRE = new Set(['long strip', 'full color', 'web comic', 'official colored', 'fan colored', 'adaptation', 'anthology', 'award winning', 'doujinshi', 'oneshot', '4-koma', 'self-published', 'user created', 'manga', 'manhwa', 'manhua', 'webtoon']) const genreName = raw => { const k = String(raw).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') if (NOT_GENRE.has(k)) return null return GENRE_ES[k] || String(raw).trim() } const MODES = ['paged', 'double', 'vertical'] const FITS = ['height', 'width'] const DIRS = ['ltr', 'rtl'] export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID }) { db.exec(` CREATE TABLE IF NOT EXISTS follows ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, manga_id TEXT NOT NULL, title TEXT NOT NULL, cover TEXT, lang TEXT, active INTEGER NOT NULL DEFAULT 1, known TEXT, -- JSON: números de capítulo ya vistos (null = sin inicializar) new_keys TEXT NOT NULL DEFAULT '[]', -- JSON: números de capítulo nuevos aún no vistos por el usuario last_checked INTEGER, last_new_at INTEGER, created_at INTEGER NOT NULL, PRIMARY KEY (user_id, manga_id) ); CREATE TABLE IF NOT EXISTS manga_prefs ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, manga_id TEXT NOT NULL, prefs TEXT NOT NULL, PRIMARY KEY (user_id, manga_id) ); CREATE TABLE IF NOT EXISTS manga_meta ( manga_id TEXT PRIMARY KEY, title TEXT NOT NULL, source TEXT, type TEXT, tags TEXT, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS page_reads ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, chapter_id TEXT NOT NULL, page INTEGER NOT NULL, manga_id TEXT NOT NULL, day TEXT NOT NULL, PRIMARY KEY (user_id, chapter_id, page) ); CREATE INDEX IF NOT EXISTS idx_page_reads_day ON page_reads(user_id, day); CREATE TABLE IF NOT EXISTS chapter_done ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, chapter_id TEXT NOT NULL, manga_id TEXT NOT NULL, day TEXT NOT NULL, PRIMARY KEY (user_id, chapter_id) ); CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT); `) if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'notify')) db.exec('ALTER TABLE users ADD COLUMN notify TEXT') // Migración única: estadísticas e historial previos a esta versión. if (!db.prepare("SELECT 1 FROM kv WHERE k = 'migr_features_v1'").get()) { const insPage = db.prepare('INSERT OR IGNORE INTO page_reads (user_id, chapter_id, page, manga_id, day) VALUES (?, ?, ?, ?, ?)') const insDone = db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)') for (const r of db.prepare('SELECT * FROM chapter_reads').all()) { const day = dayOf(r.updated_at) for (let p = 0; p <= Math.min(r.page, 500); p++) insPage.run(r.user_id, r.chapter_id, p, r.manga_id, day) if (r.total_pages && r.page >= r.total_pages - 1) insDone.run(r.user_id, r.chapter_id, r.manga_id, day) } db.prepare(`INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at) SELECT user_id, manga_id, manga_title, cover, lang, updated_at FROM progress`).run() db.prepare("INSERT INTO kv (k, v) VALUES ('migr_features_v1', ?)").run(String(Date.now())) } function saveMeta(manga) { db.prepare(`INSERT INTO manga_meta (manga_id, title, source, type, tags, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (manga_id) DO UPDATE SET title = excluded.title, source = excluded.source, type = excluded.type, tags = excluded.tags, updated_at = excluded.updated_at`) .run(manga.id, manga.title, manga.source, manga.type || null, JSON.stringify((manga.tags || []).map(t => t.name ?? t)), Date.now()) } // ---------- Notificaciones ---------- const getNotify = userId => { try { return JSON.parse(db.prepare('SELECT notify FROM users WHERE id = ?').get(userId)?.notify || '{}') } catch { return {} } } async function sendNotification(cfg, { title, message, click }) { const errors = [] if (cfg.ntfyUrl && cfg.ntfyTopic) { try { const headers = { 'Content-Type': 'application/json' } if (cfg.ntfyToken) headers.Authorization = cfg.ntfyToken.includes(':') ? `Basic ${Buffer.from(cfg.ntfyToken).toString('base64')}` : `Bearer ${cfg.ntfyToken}` // Publicación JSON: admite UTF-8 en el título (en cabeceras no). const r = await fetch(cfg.ntfyUrl, { method: 'POST', headers, signal: AbortSignal.timeout(15e3), body: JSON.stringify({ topic: cfg.ntfyTopic, title, message, click, tags: ['books'] }), }) if (!r.ok) errors.push(`ntfy: HTTP ${r.status} ${(await r.text()).slice(0, 120)}`) } catch (e) { errors.push(`ntfy: ${e.message}`) } } if (cfg.tgToken && cfg.tgChat) { try { const r = await fetch(`https://api.telegram.org/bot${cfg.tgToken}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(15e3), body: JSON.stringify({ chat_id: cfg.tgChat, text: `${title}\n${message}\n${click}`, disable_web_page_preview: true }), }) if (!r.ok) errors.push(`Telegram: HTTP ${r.status} ${(await r.text()).slice(0, 120)}`) } catch (e) { errors.push(`Telegram: ${e.message}`) } } return errors } const hasNotify = cfg => !!((cfg.ntfyUrl && cfg.ntfyTopic) || (cfg.tgToken && cfg.tgChat)) // ---------- Comprobación de capítulos nuevos ---------- const state = { checking: null, lastCheckAt: null, nextCheckAt: null } async function checkManga(mangaId, lang) { const { manga, chapters } = await fetchMangaFull(mangaId, lang || 'es-la') saveMeta(manga) if (!chapters.length) return 0 // respuesta vacía: no tocar lo conocido const keys = chapters.map(chapterKey) const byKey = new Map(chapters.map(c => [chapterKey(c), c])) let anyNew = 0 const now = Date.now() for (const f of db.prepare('SELECT * FROM follows WHERE active = 1 AND manga_id = ? AND lang IS ?').all(mangaId, lang ?? null)) { if (f.known == null) { db.prepare('UPDATE follows SET known = ?, last_checked = ? WHERE user_id = ? AND manga_id = ?').run(JSON.stringify(keys), now, f.user_id, mangaId) continue } const known = new Set(JSON.parse(f.known)) const fresh = keys.filter(k => !known.has(k)) const pending = [...new Set([...JSON.parse(f.new_keys), ...fresh])] db.prepare(`UPDATE follows SET known = ?, new_keys = ?, last_checked = ?, last_new_at = COALESCE(?, last_new_at), title = ? WHERE user_id = ? AND manga_id = ?`) .run(JSON.stringify([...known, ...fresh]), JSON.stringify(pending), now, fresh.length ? now : null, manga.title, f.user_id, mangaId) if (!fresh.length) continue anyNew += fresh.length const cfg = getNotify(f.user_id) if (hasNotify(cfg)) { const nums = fresh.map(k => byKey.get(k)?.num).filter(Boolean) const message = `${fresh.length === 1 ? '1 capítulo nuevo' : `${fresh.length} capítulos nuevos`}${nums.length ? `: ${nums.slice(0, 8).join(', ')}${nums.length > 8 ? '…' : ''}` : ''}` const errs = await sendNotification(cfg, { title: manga.title, message, click: `${PUBLIC_URL}/#/manga/${mangaId}` }) if (errs.length) console.warn('Notificación', f.user_id, errs.join('; ')) } } // Autodescarga: si el manga ya tiene copia local, bajar también lo nuevo. const dl = anyNew && downloadInfo(mangaId) if (dl && !['queued', 'downloading'].includes(dl.status)) enqueueDownload(mangaId, dl.title, dl.lang) return anyNew } function checkFollows() { if (state.checking) return state.checking state.checking = (async () => { const groups = db.prepare('SELECT manga_id, lang FROM follows WHERE active = 1 GROUP BY manga_id, lang').all() let found = 0 for (const g of groups) { try { found += await checkManga(g.manga_id, g.lang) } catch (e) { console.warn('Comprobación', g.manga_id, e.message) } await sleep(g.manga_id.startsWith('lc~') ? 1500 : 2500) } state.lastCheckAt = Date.now() if (groups.length) console.log(`Comprobados ${groups.length} mangas seguidos: ${found} capítulo(s) nuevo(s)`) })().finally(() => { state.checking = null state.nextCheckAt = Date.now() + CHECK_HOURS * 3600e3 }) return state.checking } state.nextCheckAt = Date.now() + 90e3 setTimeout(() => { checkFollows() setInterval(checkFollows, CHECK_HOURS * 3600e3) }, 90e3) // ---------- Hooks llamados desde server.js ---------- function onProgress(userId, b, page, total, prevPage) { const day = dayOf(Date.now()) const from = prevPage != null && page > prevPage && page - prevPage <= 300 ? prevPage + 1 : page const ins = db.prepare('INSERT OR IGNORE INTO page_reads (user_id, chapter_id, page, manga_id, day) VALUES (?, ?, ?, ?, ?)') for (let p = from; p <= page; p++) ins.run(userId, b.chapterId, p, b.mangaId, day) if (total && page >= total - 1) { db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)').run(userId, b.chapterId, b.mangaId, day) } // Seguir automáticamente lo que se empieza a leer (salvo que se haya dejado de seguir). db.prepare('INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at) VALUES (?, ?, ?, ?, ?, ?)') .run(userId, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null, b.mangaId.startsWith('lc~') ? 'es' : b.lang || null, Date.now()) } // Datos extra de la ficha; marca como vistos los capítulos nuevos. function mangaExtras(userId, manga) { saveMeta(manga) const f = db.prepare('SELECT active, new_keys FROM follows WHERE user_id = ? AND manga_id = ?').get(userId, manga.id) const newKeys = f?.active ? JSON.parse(f.new_keys) : [] if (newKeys.length) db.prepare("UPDATE follows SET new_keys = '[]' WHERE user_id = ? AND manga_id = ?").run(userId, manga.id) const prefs = db.prepare('SELECT prefs FROM manga_prefs WHERE user_id = ? AND manga_id = ?').get(userId, manga.id) return { following: !!f?.active, newKeys, prefs: prefs ? JSON.parse(prefs.prefs) : null } } function unfollow(userId, mangaId) { db.prepare('UPDATE follows SET active = 0 WHERE user_id = ? AND manga_id = ?').run(userId, mangaId) } // ---------- Estadísticas ---------- function stats(userId) { const pagesByDay = new Map(db.prepare('SELECT day, COUNT(*) n FROM page_reads WHERE user_id = ? GROUP BY day').all(userId).map(r => [r.day, r.n])) const chapsByDay = new Map(db.prepare('SELECT day, COUNT(*) n FROM chapter_done WHERE user_id = ? GROUP BY day').all(userId).map(r => [r.day, r.n])) const today = dayOf(Date.now()) const addDays = (d, n) => { const x = new Date(`${d}T12:00:00`); x.setDate(x.getDate() + n); return x.toLocaleDateString('sv-SE') } // Rachas: días consecutivos con alguna página leída. const days = [...pagesByDay.keys()].sort() let best = 0, run = 0, prev = null for (const d of days) { run = prev && addDays(prev, 1) === d ? run + 1 : 1 best = Math.max(best, run) prev = d } let current = 0 for (let d = pagesByDay.has(today) ? today : addDays(today, -1); pagesByDay.has(d); d = addDays(d, -1)) current++ // Semanas (lunes a domingo), las últimas 12. const monday = d => { const x = new Date(`${d}T12:00:00`); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x.toLocaleDateString('sv-SE') } const weeks = [] for (let i = 11, start = monday(today); i >= 0; i--) { const ws = addDays(start, -7 * i) let pages = 0, chapters = 0 for (let k = 0; k < 7; k++) { const d = addDays(ws, k); pages += pagesByDay.get(d) || 0; chapters += chapsByDay.get(d) || 0 } weeks.push({ start: ws, pages, chapters }) } // Calendario: últimas 18 semanas día a día. const calStart = addDays(monday(today), -7 * 17) const calendar = [] for (let d = calStart; d <= today; d = addDays(d, 1)) calendar.push({ day: d, pages: pagesByDay.get(d) || 0, chapters: chapsByDay.get(d) || 0 }) // Géneros ponderados por páginas leídas de cada manga. const perManga = db.prepare(`SELECT r.manga_id, COUNT(*) pages, m.tags, m.title, m.type FROM page_reads r LEFT JOIN manga_meta m ON m.manga_id = r.manga_id WHERE r.user_id = ? GROUP BY r.manga_id`).all(userId) const genreMap = new Map() let withMeta = 0 for (const m of perManga) { if (!m.tags) continue withMeta += m.pages const names = [...new Set(JSON.parse(m.tags).map(genreName).filter(Boolean))] for (const g of names) genreMap.set(g, (genreMap.get(g) || 0) + m.pages) } const genres = [...genreMap].sort((a, b) => b[1] - a[1]).slice(0, 10) .map(([name, pages]) => ({ name, pages, pct: withMeta ? Math.round((pages / withMeta) * 100) : 0 })) const chapByManga = new Map(db.prepare('SELECT manga_id, COUNT(*) n FROM chapter_done WHERE user_id = ? GROUP BY manga_id').all(userId).map(r => [r.manga_id, r.n])) const progress = new Map(db.prepare('SELECT manga_id, manga_title, cover FROM progress WHERE user_id = ?').all(userId).map(r => [r.manga_id, r])) const topMangas = perManga.sort((a, b) => b.pages - a.pages).slice(0, 8).map(m => ({ id: m.manga_id, title: m.title || progress.get(m.manga_id)?.manga_title || m.manga_id, cover: progress.get(m.manga_id)?.cover || null, pages: m.pages, chapters: chapByManga.get(m.manga_id) || 0, })) const types = new Map() for (const m of perManga) if (m.type) types.set(m.type, (types.get(m.type) || 0) + m.pages) return { totals: { pages: [...pagesByDay.values()].reduce((a, b) => a + b, 0), chapters: [...chapsByDay.values()].reduce((a, b) => a + b, 0), mangas: perManga.length, activeDays: days.length, thisWeekPages: weeks.at(-1).pages, thisWeekChapters: weeks.at(-1).chapters, }, streak: { current, best, readToday: pagesByDay.has(today) }, weeks, calendar, genres, topMangas, types: [...types].sort((a, b) => b[1] - a[1]).map(([name, pages]) => ({ name, pages })), } } // ---------- Rutas ---------- async function route(p, method, q, req, res, user) { let m if (p === '/api/follows' && method === 'GET') { const list = db.prepare('SELECT manga_id, title, cover, lang, new_keys, last_checked, last_new_at FROM follows WHERE user_id = ? AND active = 1 ORDER BY COALESCE(last_new_at, 0) DESC, created_at DESC').all(user.id) return json(res, 200, { follows: list.map(f => ({ ...f, newCount: JSON.parse(f.new_keys).length, new_keys: undefined })), checking: !!state.checking, lastCheckAt: state.lastCheckAt, nextCheckAt: state.nextCheckAt, intervalHours: CHECK_HOURS, }) } if (p === '/api/follows/check' && method === 'POST') { checkFollows() return json(res, 200, { checking: true }) } if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { const b = await readBody(req) const lang = m[1].startsWith('lc~') ? 'es' : /^[a-z]{2}(-[a-z]{2})?$/.test(b.lang || '') ? b.lang : user.lang db.prepare(`INSERT INTO follows (user_id, manga_id, title, cover, lang, active, created_at) VALUES (?, ?, ?, ?, ?, 1, ?) ON CONFLICT (user_id, manga_id) DO UPDATE SET active = 1, lang = excluded.lang, title = excluded.title, cover = excluded.cover, known = CASE WHEN follows.lang IS excluded.lang THEN follows.known ELSE NULL END`) .run(user.id, m[1], String(b.title || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null, lang, Date.now()) checkManga(m[1], lang).catch(e => console.warn('Seguir', m[1], e.message)) // inicializa lo conocido return json(res, 200, { following: true }) } if ((m = /^\/api\/follow\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') { unfollow(user.id, m[1]) return json(res, 200, { following: false }) } if ((m = /^\/api\/prefs\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { const b = await readBody(req) if (!MODES.includes(b.mode) || !FITS.includes(b.fit) || !DIRS.includes(b.dir)) throw new HttpError(400, 'Preferencias inválidas') db.prepare('INSERT OR REPLACE INTO manga_prefs (user_id, manga_id, prefs) VALUES (?, ?, ?)') .run(user.id, m[1], JSON.stringify({ mode: b.mode, fit: b.fit, dir: b.dir })) return json(res, 200, { ok: true }) } if (p === '/api/stats' && method === 'GET') return json(res, 200, stats(user.id)) if (p === '/api/notify' && method === 'GET') { const cfg = getNotify(user.id) return json(res, 200, { ntfyUrl: cfg.ntfyUrl || '', ntfyTopic: cfg.ntfyTopic || '', hasNtfyToken: !!cfg.ntfyToken, tgChat: cfg.tgChat || '', hasTgToken: !!cfg.tgToken }) } if (p === '/api/notify' && method === 'POST') { const b = await readBody(req) const old = getNotify(user.id) const str = (v, max) => String(v ?? '').trim().slice(0, max) const cfg = { ntfyUrl: str(b.ntfyUrl, 300).replace(/\/+$/, ''), ntfyTopic: str(b.ntfyTopic, 64), // Campo de token vacío = conservar el guardado; "-" = borrarlo. ntfyToken: b.ntfyToken === '-' ? '' : str(b.ntfyToken, 300) || old.ntfyToken || '', tgToken: b.tgToken === '-' ? '' : str(b.tgToken, 100) || old.tgToken || '', tgChat: str(b.tgChat, 40), } if (cfg.ntfyUrl && !/^https?:\/\/[^\s/]+/.test(cfg.ntfyUrl)) throw new HttpError(400, 'URL de ntfy inválida') if (cfg.ntfyTopic && !/^[\w-]{1,64}$/.test(cfg.ntfyTopic)) throw new HttpError(400, 'Tema de ntfy inválido (letras, números, - y _)') if (cfg.tgToken && !/^\d+:[\w-]{20,}$/.test(cfg.tgToken)) throw new HttpError(400, 'Token de Telegram inválido') if (cfg.tgChat && !/^-?\d+$|^@\w+$/.test(cfg.tgChat)) throw new HttpError(400, 'Chat de Telegram inválido') db.prepare('UPDATE users SET notify = ? WHERE id = ?').run(JSON.stringify(cfg), user.id) return json(res, 200, { ok: true }) } if (p === '/api/notify/test' && method === 'POST') { const cfg = getNotify(user.id) if (!hasNotify(cfg)) throw new HttpError(400, 'No hay ningún canal de notificación configurado') const errors = await sendNotification(cfg, { title: 'Manga Reader', message: 'Notificación de prueba: los avisos de capítulos nuevos llegarán aquí.', click: PUBLIC_URL }) if (errors.length) throw new HttpError(502, errors.join(' · ')) return json(res, 200, { ok: true }) } return false } return { route, onProgress, mangaExtras, unfollow, checkFollows } }