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:
+10
-1
@@ -6,6 +6,7 @@ import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js'
|
||||
import { setupFeatures } from './features.js'
|
||||
|
||||
const PORT = Number(process.env.PORT || 3352)
|
||||
const DATA_DIR = process.env.DATA_DIR || '/app/data'
|
||||
@@ -170,6 +171,7 @@ function mangaSummary(m, lang) {
|
||||
tags: (a.tags || []).map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']) })).slice(0, 12),
|
||||
authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name),
|
||||
languages: a.availableTranslatedLanguages || [],
|
||||
type: { ja: 'Manga', ko: 'Manhwa', zh: 'Manhua', 'zh-hk': 'Manhua' }[a.originalLanguage] || 'Cómic',
|
||||
cover,
|
||||
}
|
||||
}
|
||||
@@ -353,6 +355,7 @@ async function lcManga(id) {
|
||||
title: decode(/<h1[^>]*>([^<]+)/.exec(html)?.[1]) || slug,
|
||||
description: decode(sinopsis?.replace(/<br\s*\/?>/g, '\n')),
|
||||
status: fact('Estado'),
|
||||
type: fact('Tipo') || null,
|
||||
year: null,
|
||||
tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+)</g)].map(m => ({ id: `${m[1]}:${m[2]}`, name: decode(m[3]) })).slice(0, 12),
|
||||
authors: fact('Autor') ? [fact('Autor')] : [],
|
||||
@@ -674,6 +677,7 @@ async function route(req, res) {
|
||||
|
||||
// Todo lo demás requiere sesión.
|
||||
const user = requireUser(req)
|
||||
if ((await features.route(p, method, q, req, res, user)) !== false) return
|
||||
|
||||
if (p === '/api/settings' && method === 'POST') {
|
||||
const body = await readBody(req)
|
||||
@@ -725,7 +729,7 @@ async function route(req, res) {
|
||||
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
|
||||
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
|
||||
const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
|
||||
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true) })
|
||||
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), ...features.mangaExtras(user.id, manga) })
|
||||
}
|
||||
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
|
||||
const local = localChapter(m[1])
|
||||
@@ -783,6 +787,7 @@ async function route(req, res) {
|
||||
const page = Math.max(0, Number(b.page) | 0)
|
||||
const total = Math.max(0, Number(b.totalPages) | 0)
|
||||
const now = Date.now()
|
||||
const prevPage = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, b.chapterId)?.page
|
||||
db.prepare(`INSERT INTO progress (user_id, manga_id, manga_title, cover, chapter_id, chapter_num, chapter_title, lang, page, total_pages, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, manga_id) DO UPDATE SET manga_title = excluded.manga_title, cover = excluded.cover,
|
||||
@@ -794,17 +799,21 @@ async function route(req, res) {
|
||||
db.prepare(`INSERT INTO chapter_reads (user_id, chapter_id, manga_id, page, total_pages, updated_at) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, chapter_id) DO UPDATE SET page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`)
|
||||
.run(user.id, b.chapterId, b.mangaId, page, total, now)
|
||||
features.onProgress(user.id, b, page, total, prevPage)
|
||||
return json(res, 200, { ok: true })
|
||||
}
|
||||
if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
|
||||
db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
|
||||
db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
|
||||
features.unfollow(user.id, m[1])
|
||||
return json(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
throw new HttpError(404, 'No encontrado')
|
||||
}
|
||||
|
||||
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID })
|
||||
|
||||
// Migración: portadas guardadas antes de firmar las URLs del proxy.
|
||||
for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) {
|
||||
const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u')
|
||||
|
||||
Reference in New Issue
Block a user