Colas por usuario y sin pantalla negra mientras carga la página siguiente

- Descargas y reescalados tienen usuarios (download_users): Colas y la biblioteca descargada muestran
  solo lo de cada uno. Un manga ya descargado por otro se lee desde el disco y entra en tu biblioteca
  al empezar a leerlo; borrar uno compartido solo lo quita de la tuya. Lo anterior se asigna a quien lo
  lee, sigue o tiene en una lista (o al primer usuario).
- Lector: si la página siguiente no ha llegado, la actual se queda con un indicador hasta que llega
  (máx. 4 s); si no, la nueva aparece con fundido y un indicador en vez de negro. Precarga de 6
  páginas por delante, ya decodificadas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 16:20:09 -06:00
co-authored by Claude Opus 5.5
parent 84a60a6501
commit 09354f675e
4 changed files with 114 additions and 20 deletions
+2 -1
View File
@@ -6,7 +6,7 @@ const REC_TTL = 6 * 3600e3
import { genreName } from './features.js' import { genreName } from './features.js'
const norm = s => String(s).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') const norm = s => String(s).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMangaFull, enqueueDownload, upscale, stats, searchManga, mdCategories, net }) { export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats, searchManga, mdCategories, net }) {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS lists ( CREATE TABLE IF NOT EXISTS lists (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -69,6 +69,7 @@ export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMan
const missing = next.filter(id => !have.has(id)) const missing = next.filter(id => !have.has(id))
if (missing.length) { if (missing.length) {
enqueueDownload(b.mangaId, manga.title, lang, missing) enqueueDownload(b.mangaId, manga.title, lang, missing)
linkDownload(userId, b.mangaId)
console.log(`Descarga anticipada: ${manga.title} → ${missing.length} cap(s).`) console.log(`Descarga anticipada: ${manga.title} → ${missing.length} cap(s).`)
} }
if (cfg.hd && upscale.available()) upscale.request(next) if (cfg.hd && upscale.available()) upscale.request(next)
+61 -12
View File
@@ -905,9 +905,10 @@ async function viewManga(id, langOverride) {
$('#dlopen')?.addEventListener('click', () => setSel(sel?.kind === 'dl' ? null : { kind: 'dl', mode: 'range' })) $('#dlopen')?.addEventListener('click', () => setSel(sel?.kind === 'dl' ? null : { kind: 'dl', mode: 'range' }))
$('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' }))) $('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' })))
$('#dldel')?.addEventListener('click', async () => { $('#dldel')?.addEventListener('click', async () => {
if (!confirm(`¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}, incluida la versión HD.\nTu historial de lectura no se toca.`)) return if (!confirm(d.shared ? `¿Quitar «${manga.title}» de tu biblioteca descargada?\nOtro usuario también lo tiene: los archivos se quedan para él y lo puedes seguir leyendo.`
await api(`/api/download/${id}`, { method: 'DELETE' }) : `¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}, incluida la versión HD.\nTu historial de lectura no se toca.`)) return
toast('Copia local eliminada') const r = await api(`/api/download/${id}`, { method: 'DELETE' })
toast(r.kept ? 'Quitado de tu biblioteca' : 'Copia local eliminada')
viewManga(id, langOverride) viewManga(id, langOverride)
}) })
const wantedBefore = wantedSet.size const wantedBefore = wantedSet.size
@@ -1412,24 +1413,67 @@ async function viewReader(mangaId, chapterId, startPage) {
} }
return [i] return [i]
} }
// Las 4 siguientes y las 2 anteriores (para que al retroceder la hoja tenga ya su reverso). // Las 6 siguientes y las 2 anteriores (para que al retroceder la hoja tenga ya su reverso), ya
// decodificadas. Se guardan las referencias para que la descarga no se pierda si se recoge la basura.
const preloaded = new Map()
function preload() { function preload() {
const load = i => { const want = []
for (let k = 1; k <= 6 && shown.at(-1) + k < total; k++) want.push(shown.at(-1) + k)
for (let k = 1; k <= 2 && shown[0] - k >= 0; k++) want.push(shown[0] - k)
for (const i of want) {
if (preloaded.has(i)) continue
const im = new Image() const im = new Image()
im.onload = () => isWide(im) && wide.add(i) im.onload = () => isWide(im) && wide.add(i)
im.src = pages[i] im.src = pages[i]
im.decode?.().catch(() => {})
preloaded.set(i, im)
} }
for (let k = 1; k <= 4 && shown.at(-1) + k < total; k++) load(shown.at(-1) + k) for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i)
for (let k = 1; k <= 2 && shown[0] - k >= 0; k++) load(shown[0] - k)
} }
const imgReady = im => im.complete && im.naturalWidth > 0
// Hacia una página que aún no ha llegado, la actual se queda (con un indicador tras un momento) hasta que
// llegue o pasen 4 s, en vez de pasar la hoja y dejar negro debajo. Si se pide otra página mientras,
// manda la última.
let showSeq = 0, waitTimer = 0
// dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…). // dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…).
function showPaged(dir = 0) { function showPaged(dir = 0) {
const seq = ++showSeq
clearTimeout(waitTimer)
box.classList.remove('waiting')
finishTurn() finishTurn()
const old = $('.stage', box)
const stage = document.createElement('div') const stage = document.createElement('div')
stage.className = 'stage' stage.className = 'stage'
stage.classList.toggle('two', shown.length === 2) stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('') stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('')
const imgs = [...stage.querySelectorAll('img')]
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) {
waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
const arrived = Promise.all(imgs.map(im => (im.decode ? im.decode() : new Promise(r => { im.onload = im.onerror = r })).catch(() => {})))
Promise.race([arrived, new Promise(r => setTimeout(r, 4000))]).then(() => {
if (seq !== showSeq) return
clearTimeout(waitTimer)
box.classList.remove('waiting')
if (!atEnd && isPaged() && box.isConnected) presentPaged(stage, dir)
})
return
}
presentPaged(stage, dir)
}
function presentPaged(stage, dir) {
const old = $('.stage', box)
// Lo que aún no ha llegado se ve con un indicador de carga y aparece con un fundido, no de golpe.
const imgs = [...stage.querySelectorAll('img')]
const pending = imgs.filter(im => !imgReady(im))
pending.forEach(im => {
im.classList.add('pending')
const done = () => {
im.classList.remove('pending')
if (!stage.querySelector('img.pending')) stage.classList.remove('loading')
}
im.addEventListener('load', done, { once: true })
im.addEventListener('error', done, { once: true })
})
stage.classList.toggle('loading', pending.length > 0)
// Al arrastrar, el sonido sale al soltar (si la página llega a pasar). // Al arrastrar, el sonido sale al soltar (si la página llega a pasar).
if (old && dir && !wantDrag) turnSound(settings.sfx, turnPan(dir)) if (old && dir && !wantDrag) turnSound(settings.sfx, turnPan(dir))
if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag) if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
@@ -1672,6 +1716,9 @@ async function viewReader(mangaId, chapterId, startPage) {
} }
function showEnd() { function showEnd() {
finishTurn() finishTurn()
showSeq++ // descarta una página que estuviera esperando a cargar
clearTimeout(waitTimer)
box.classList.remove('waiting')
atEnd = true atEnd = true
resetZoom() resetZoom()
box.innerHTML = endBlock box.innerHTML = endBlock
@@ -2468,8 +2515,8 @@ async function viewQueues() {
pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null, pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null,
lines: `<div>${esc(downloadLabel(d))}</div><div>${fmtSize(d.size)}</div> lines: `<div>${esc(downloadLabel(d))}</div><div>${fmtSize(d.size)}</div>
${d.status === 'error' ? `<div class="error" title="${esc(d.error || '')}">Con errores</div>` : ''}`, ${d.status === 'error' ? `<div class="error" title="${esc(d.error || '')}">Con errores</div>` : ''}`,
del: `<button class="hdel" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}" title="Eliminar la copia local (mangas/${esc(d.folder)})" aria-label="Eliminar la copia local">${icon('trash')}</button>`, del: `<button class="hdel" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}" ${d.shared ? 'data-shared="1"' : ''} title="${d.shared ? 'Quitar de tu biblioteca (otro usuario también lo tiene)' : `Eliminar la copia local (mangas/${esc(d.folder)})`}" aria-label="Eliminar la copia local">${icon('trash')}</button>`,
})).join('')}</div>` : `<p class="muted">${list.length ? 'Todo lo descargado está aún en la cola.' : 'Aún no has descargado nada. Usa “Descargar” en la ficha de un manga.'}</p>`} })).join('')}</div>` : `<p class="muted">${list.length ? 'Todo lo descargado está aún en la cola.' : 'Aún no tienes nada descargado. Usa “Descargar” en la ficha de un manga; lo que ya descargó otro usuario aparece aquí en cuanto lo empiezas a leer.'}</p>`}
</section>` </section>`
// Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar). // Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar).
let wrap = $('#qwrap') let wrap = $('#qwrap')
@@ -2486,8 +2533,10 @@ async function viewQueues() {
else if (b.dataset.devcancel) { offCancel(b.dataset.devcancel); toast('Guardado cancelado') } else if (b.dataset.devcancel) { offCancel(b.dataset.devcancel); toast('Guardado cancelado') }
else if (b.dataset.retry) act(() => api(`/api/upscale/${b.dataset.retry}`, { method: 'POST', body: { chapterIds: b.dataset.ids.split(' ') } }), 'Vuelto a la cola') else if (b.dataset.retry) act(() => api(`/api/upscale/${b.dataset.retry}`, { method: 'POST', body: { chapterIds: b.dataset.ids.split(' ') } }), 'Vuelto a la cola')
else if (b.dataset.del) { else if (b.dataset.del) {
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return const shared = !!b.dataset.shared
act(() => api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }), 'Copia local eliminada') if (!confirm(shared ? `¿Quitar «${b.dataset.title}» de tu biblioteca descargada?\nOtro usuario también lo tiene: los archivos se quedan.`
: `¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
act(() => api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }), shared ? 'Quitado de tu biblioteca' : 'Copia local eliminada')
} }
}) })
} else { } else {
+8 -1
View File
@@ -101,7 +101,14 @@ input:focus, select:focus { border-color: var(--accent); }
.rbar select { padding: 5px 8px; } .rbar select { padding: 5px 8px; }
.rbar input[type=range] { flex: 1; max-width: 520px; accent-color: var(--accent); } .rbar input[type=range] { flex: 1; max-width: 520px; accent-color: var(--accent); }
#pages img { filter: var(--page-filter, none); transition: filter .2s; } #pages img { filter: var(--page-filter, none); transition: filter .2s, opacity .25s; }
/* Página que aún no ha llegado: indicador de carga y fundido al aparecer */
#pages .stage img.pending { opacity: 0; }
.paged.waiting::after, .paged .stage.loading::after { content: ''; position: absolute; left: 50%; top: 50%; width: 34px; height: 34px;
margin: -17px 0 0 -17px; border-radius: 50%; border: 3px solid rgba(255,255,255,.18); border-top-color: var(--accent);
animation: pgspin .8s linear infinite; z-index: 6; pointer-events: none; }
.paged .stage.loading { min-height: 100%; }
@keyframes pgspin { to { transform: rotate(360deg); } }
.paged { height: 100vh; height: 100dvh; display: flex; align-items: center; justify-content: center; user-select: none; } .paged { height: 100vh; height: 100dvh; display: flex; align-items: center; justify-content: center; user-select: none; }
.paged img { max-width: 100%; max-height: 100%; object-fit: contain; } .paged img { max-width: 100%; max-height: 100%; object-fit: contain; }
.paged.fit-width { height: auto; min-height: 100vh; align-items: flex-start; } .paged.fit-width { height: auto; min-height: 100vh; align-items: flex-start; }
+43 -6
View File
@@ -122,6 +122,18 @@ db.exec(`CREATE TABLE IF NOT EXISTS download_wanted (
manga_id TEXT NOT NULL REFERENCES downloads(manga_id) ON DELETE CASCADE, manga_id TEXT NOT NULL REFERENCES downloads(manga_id) ON DELETE CASCADE,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
)`) )`)
// Usuarios de cada manga descargado: quién lo pidió (descarga, reescalado, descarga anticipada) o lo
// empezó a leer ya descargado. Colas y "Biblioteca descargada" muestran solo lo de cada usuario; los
// archivos son uno para todos, y se borran cuando ya no lo tiene nadie.
db.exec(`CREATE TABLE IF NOT EXISTS download_users (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, manga_id)
)`)
const linkDownload = (userId, mangaId) => db.prepare('INSERT OR IGNORE INTO download_users (user_id, manga_id, created_at) VALUES (?, ?, ?)').run(userId, mangaId, Date.now())
const sharedWith = (userId, mangaId) => !!db.prepare('SELECT 1 FROM download_users WHERE manga_id = ? AND user_id != ?').get(mangaId, userId)
const MY_DOWNLOADS = 'manga_id IN (SELECT manga_id FROM download_users WHERE user_id = ?)'
// finished_at: cuándo se terminó la última página del último capítulo disponible (null = en curso). // finished_at: cuándo se terminó la última página del último capítulo disponible (null = en curso).
if (!db.prepare('PRAGMA table_info(progress)').all().some(c => c.name === 'finished_at')) db.exec('ALTER TABLE progress ADD COLUMN finished_at INTEGER') if (!db.prepare('PRAGMA table_info(progress)').all().some(c => c.name === 'finished_at')) db.exec('ALTER TABLE progress ADD COLUMN finished_at INTEGER')
@@ -591,6 +603,7 @@ function deleteDownload(mangaId) {
const dir = mangaDirOf(dl.folder) const dir = mangaDirOf(dl.folder)
upscale.cancel(mangaId) upscale.cancel(mangaId)
db.prepare('DELETE FROM downloads WHERE manga_id = ?').run(mangaId) // borra también downloaded_chapters (CASCADE) db.prepare('DELETE FROM downloads WHERE manga_id = ?').run(mangaId) // borra también downloaded_chapters (CASCADE)
db.prepare('DELETE FROM download_users WHERE manga_id = ?').run(mangaId)
dlCurrent.delete(mangaId) dlCurrent.delete(mangaId)
fs.rmSync(dir, { recursive: true, force: true }) fs.rmSync(dir, { recursive: true, force: true })
return true return true
@@ -847,7 +860,11 @@ async function route(req, res) {
const user = requireUser(req) const user = requireUser(req)
if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return
if ((await features.route(p, method, q, req, res, user)) !== false) return if ((await features.route(p, method, q, req, res, user)) !== false) return
if ((await upscale.route(p, method, req, res)) !== false) return if ((await upscale.route(p, method, req, res)) !== false) {
// Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide.
if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1])
return
}
if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== false) return if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== false) return
if (p.startsWith('/api/meta/') && MANGA_ID.test(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return if (p.startsWith('/api/meta/') && MANGA_ID.test(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return
if (/^\/api\/(lists|prefetch|recommendations)\b/.test(p) && (await library.route(p, method, q, req, res, user)) !== false) return if (/^\/api\/(lists|prefetch|recommendations)\b/.test(p) && (await library.route(p, method, q, req, res, user)) !== false) return
@@ -962,10 +979,13 @@ async function route(req, res) {
if (p === '/api/status' && method === 'GET') return json(res, 200, net.status()) if (p === '/api/status' && method === 'GET') return json(res, 200, net.status())
if (p === '/api/queues' && method === 'GET') { if (p === '/api/queues' && method === 'GET') {
const downloads = db.prepare(`SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover const downloads = db.prepare(`SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover
FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at`).all() FROM downloads WHERE status IN ('downloading', 'queued') AND ${MY_DOWNLOADS} ORDER BY status = 'downloading' DESC, created_at`).all(user.id)
.map(({ meta_cover, ...d }) => ({ ...withCurrent(d), cover: downloadCover(d.manga_id, d.folder, meta_cover) })) .map(({ meta_cover, ...d }) => ({ ...withCurrent(d), cover: downloadCover(d.manga_id, d.folder, meta_cover) }))
const c = clips.queueInfo(user.id) const c = clips.queueInfo(user.id)
const up = upscale.queue() const up = upscale.queue()
const mine = new Set(db.prepare('SELECT manga_id FROM download_users WHERE user_id = ?').all(user.id).map(r => r.manga_id))
up.groups = up.groups.filter(g => mine.has(g.mangaId))
if (up.current && !mine.has(up.current.mangaId)) up.current = null
for (const g of up.groups) { for (const g of up.groups) {
const d = db.prepare(`SELECT folder, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE manga_id = ?`).get(g.mangaId) const d = db.prepare(`SELECT folder, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE manga_id = ?`).get(g.mangaId)
g.cover = d ? downloadCover(g.mangaId, d.folder, d.meta_cover) : null g.cover = d ? downloadCover(g.mangaId, d.folder, d.meta_cover) : null
@@ -974,11 +994,12 @@ async function route(req, res) {
} }
if (p === '/api/downloads' && method === 'GET') { if (p === '/api/downloads' && method === 'GET') {
return json(res, 200, db.prepare(`SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover return json(res, 200, db.prepare(`SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover
FROM downloads ORDER BY updated_at DESC`).all() FROM downloads WHERE ${MY_DOWNLOADS} ORDER BY updated_at DESC`).all(user.id)
.map(({ meta_cover, ...d }) => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)), cover: downloadCover(d.manga_id, d.folder, meta_cover) }))) .map(({ meta_cover, ...d }) => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)), cover: downloadCover(d.manga_id, d.folder, meta_cover), shared: sharedWith(user.id, d.manga_id) })))
} }
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
return json(res, 200, downloadInfo(m[1], true)) const d = downloadInfo(m[1], true)
return json(res, 200, d && { ...d, shared: sharedWith(user.id, m[1]) })
} }
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const b = await readBody(req) const b = await readBody(req)
@@ -987,9 +1008,12 @@ async function route(req, res) {
const ids = Array.isArray(b.chapterIds) ? b.chapterIds.map(String).filter(id => CHAPTER_ID.test(id)).slice(0, 5000) : null const ids = Array.isArray(b.chapterIds) ? b.chapterIds.map(String).filter(id => CHAPTER_ID.test(id)).slice(0, 5000) : null
if (ids && !ids.length) throw new HttpError(400, 'Ningún capítulo válido') if (ids && !ids.length) throw new HttpError(400, 'Ningún capítulo válido')
enqueueDownload(m[1], title, dlLang, ids) enqueueDownload(m[1], title, dlLang, ids)
linkDownload(user.id, m[1])
return json(res, 200, downloadInfo(m[1], true)) return json(res, 200, downloadInfo(m[1], true))
} }
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') { if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
// Si otro usuario también lo tiene, solo se quita de la biblioteca de este (los archivos se quedan).
if (sharedWith(user.id, m[1]) && db.prepare('DELETE FROM download_users WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]).changes) return json(res, 200, { ok: true, kept: true })
if (!deleteDownload(m[1])) throw new HttpError(404, 'No hay copia local') if (!deleteDownload(m[1])) throw new HttpError(404, 'No hay copia local')
return json(res, 200, { ok: true }) return json(res, 200, { ok: true })
} }
@@ -1029,6 +1053,7 @@ async function route(req, res) {
db.prepare(`INSERT INTO chapter_reads (user_id, chapter_id, manga_id, page, total_pages, updated_at) VALUES (?, ?, ?, ?, ?, ?) 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`) 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) .run(user.id, b.chapterId, b.mangaId, page, total, now)
if (db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(b.mangaId)) linkDownload(user.id, b.mangaId)
features.onProgress(user.id, b, page, total, prevPage) features.onProgress(user.id, b, page, total, prevPage)
library.onProgress(user.id, b, page, total) library.onProgress(user.id, b, page, total)
return json(res, 200, { ok: true }) return json(res, 200, { ok: true })
@@ -1047,12 +1072,24 @@ const security = setupSecurity({ db })
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net }) const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net })
const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID })
const covers = setupCovers({ DATA_DIR, upscale }) const covers = setupCovers({ DATA_DIR, upscale })
const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, upscale, stats: features.stats, searchManga, mdCategories, net }) const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats: features.stats, searchManga, mdCategories, net })
// Al volver internet: reanudar las descargas en cola. // Al volver internet: reanudar las descargas en cola.
net.whenOnline(() => pumpDownloads()) net.whenOnline(() => pumpDownloads())
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA }) const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA })
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
// Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una
// lista; si no es de nadie, al primer usuario (el que montó el servidor).
if (!db.prepare("SELECT 1 FROM kv WHERE k = 'download_users_init'").get()) {
const first = db.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get()?.id
for (const { manga_id } of db.prepare('SELECT manga_id FROM downloads').all()) {
const users = db.prepare(`SELECT user_id FROM progress WHERE manga_id = ? UNION SELECT user_id FROM follows WHERE manga_id = ?
UNION SELECT l.user_id FROM list_items i JOIN lists l ON l.id = i.list_id WHERE i.manga_id = ?`).all(manga_id, manga_id, manga_id).map(r => r.user_id)
for (const u of users.length ? users : first ? [first] : []) linkDownload(u, manga_id)
}
if (first) db.prepare("INSERT INTO kv (k, v) VALUES ('download_users_init', '1')").run()
}
// Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex. // Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex.
for (const t of ['progress', 'follows', 'clips']) { for (const t of ['progress', 'follows', 'clips']) {
for (const row of db.prepare(`SELECT DISTINCT manga_id, cover FROM ${t} WHERE cover LIKE '/api/img?u=%&s=%' AND cover NOT LIKE '%&m=%'`).all()) { for (const row of db.prepare(`SELECT DISTINCT manga_id, cover FROM ${t} WHERE cover LIKE '/api/img?u=%&s=%' AND cover NOT LIKE '%&m=%'`).all()) {