Novedades, ajustes del lector por manga, doble página, zoom y estadísticas
- Seguimiento de mangas (automático al empezar a leer o con "Seguir"): comprobación cada CHECK_INTERVAL_HOURS, "N caps. nuevos" en el inicio, marcas NUEVO en la ficha, autodescarga si hay copia local y avisos por ntfy o Telegram configurables en Ajustes. - Modo, ajuste y dirección del lector guardados por manga en el servidor, con valores por defecto según el tipo (manhwa/manhua vertical, manga der→izq). - Modo doble página (portada y páginas apaisadas solas; vuelve a una página en pantallas estrechas o en vertical). - Zoom: pellizco, doble toque en el centro, Ctrl+rueda y teclas + - 0. - Opciones del lector en un panel desplegable. - Página de estadísticas: rachas, capítulos/páginas por semana, calendario, géneros favoritos y lo más leído (con migración de las lecturas previas). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+377
@@ -0,0 +1,377 @@
|
||||
// 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user