Files
manga-reader/app/features.js
T

384 lines
22 KiB
JavaScript

// 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://localhost: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'])
export 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, net }) {
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)
// Solo si se baja el manga entero; con capítulos sueltos, lo nuevo no se descarga solo.
if (dl && dl.scope !== 'some' && !['queued', 'downloading'].includes(dl.status)) enqueueDownload(mangaId, dl.title, dl.lang)
return anyNew
}
let skippedOffline = false
function checkFollows() {
if (state.checking) return state.checking
// Sin internet no se puede comprobar nada: se hace en cuanto vuelva la conexión.
if (!net.online()) { skippedOffline = true; return Promise.resolve() }
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(/^(lc|mi)~/.test(g.manga_id) ? 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
}
net.whenOnline(() => { if (skippedOffline) { skippedOffline = false; checkFollows() } })
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)
}
if (/^(gb|ws|ub)~/.test(b.mangaId)) return // un libro no tiene capítulos nuevos que seguir
// 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,
/^(lc|mi)~/.test(b.mangaId) ? '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 = /^(lc|mi)~/.test(m[1]) ? '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) || (b.gap != null && typeof b.gap !== 'boolean')) 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, ...(b.gap != null ? { gap: b.gap } : {}) }))
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, stats }
}