diff --git a/app/library.js b/app/library.js index f510127..0e38e2e 100644 --- a/app/library.js +++ b/app/library.js @@ -6,7 +6,7 @@ const REC_TTL = 6 * 3600e3 import { genreName } from './features.js' 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(` CREATE TABLE IF NOT EXISTS lists ( 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)) if (missing.length) { enqueueDownload(b.mangaId, manga.title, lang, missing) + linkDownload(userId, b.mangaId) console.log(`Descarga anticipada: ${manga.title} → ${missing.length} cap(s).`) } if (cfg.hd && upscale.available()) upscale.request(next) diff --git a/app/public/app.js b/app/public/app.js index 4dfa201..574fcfe 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -905,9 +905,10 @@ async function viewManga(id, langOverride) { $('#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' }))) $('#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 - await api(`/api/download/${id}`, { method: 'DELETE' }) - toast('Copia local eliminada') + 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.` + : `¿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 + const r = await api(`/api/download/${id}`, { method: 'DELETE' }) + toast(r.kept ? 'Quitado de tu biblioteca' : 'Copia local eliminada') viewManga(id, langOverride) }) const wantedBefore = wantedSet.size @@ -1412,24 +1413,67 @@ async function viewReader(mangaId, chapterId, startPage) { } 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() { - 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() im.onload = () => isWide(im) && wide.add(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 (let k = 1; k <= 2 && shown[0] - k >= 0; k++) load(shown[0] - k) + for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i) } + 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…). function showPaged(dir = 0) { + const seq = ++showSeq + clearTimeout(waitTimer) + box.classList.remove('waiting') finishTurn() - const old = $('.stage', box) const stage = document.createElement('div') stage.className = 'stage' stage.classList.toggle('two', shown.length === 2) stage.innerHTML = shown.map(i => ``).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). if (old && dir && !wantDrag) turnSound(settings.sfx, turnPan(dir)) 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() { finishTurn() + showSeq++ // descarta una página que estuviera esperando a cargar + clearTimeout(waitTimer) + box.classList.remove('waiting') atEnd = true resetZoom() box.innerHTML = endBlock @@ -2468,8 +2515,8 @@ async function viewQueues() { pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null, lines: `
${esc(downloadLabel(d))}
${fmtSize(d.size)}
${d.status === 'error' ? `
Con errores
` : ''}`, - del: ``, - })).join('')}` : `

${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.'}

`} + del: ``, + })).join('')}` : `

${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.'}

`} ` // Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar). 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.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) { - if (!confirm(`¿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' }), 'Copia local eliminada') + const shared = !!b.dataset.shared + 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 { diff --git a/app/public/style.css b/app/public/style.css index 2f40088..fbbdc3a 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -101,7 +101,14 @@ input:focus, select:focus { border-color: var(--accent); } .rbar select { padding: 5px 8px; } .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 img { max-width: 100%; max-height: 100%; object-fit: contain; } .paged.fit-width { height: auto; min-height: 100vh; align-items: flex-start; } diff --git a/app/server.js b/app/server.js index bbbdca7..b0ec36f 100644 --- a/app/server.js +++ b/app/server.js @@ -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, 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). 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) upscale.cancel(mangaId) 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) fs.rmSync(dir, { recursive: true, force: true }) return true @@ -847,7 +860,11 @@ async function route(req, res) { const user = requireUser(req) 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 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/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 @@ -962,10 +979,13 @@ async function route(req, res) { if (p === '/api/status' && method === 'GET') return json(res, 200, net.status()) 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 - 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) })) const c = clips.queueInfo(user.id) 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) { 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 @@ -974,11 +994,12 @@ async function route(req, res) { } 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 - FROM downloads ORDER BY updated_at DESC`).all() - .map(({ meta_cover, ...d }) => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)), cover: downloadCover(d.manga_id, d.folder, meta_cover) }))) + 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), shared: sharedWith(user.id, d.manga_id) }))) } 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') { 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 if (ids && !ids.length) throw new HttpError(400, 'Ningún capítulo válido') enqueueDownload(m[1], title, dlLang, ids) + linkDownload(user.id, m[1]) return json(res, 200, downloadInfo(m[1], true)) } 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') 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 (?, ?, ?, ?, ?, ?) 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) + 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) library.onProgress(user.id, b, page, total) 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 upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) 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. net.whenOnline(() => pumpDownloads()) 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 }) +// 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. 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()) {