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:
+2
-1
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user