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