From d49f739565a8a65523153e1745a12f502e25575e Mon Sep 17 00:00:00 2001 From: lenafx Date: Sat, 27 Dec 2025 21:59:03 +0100 Subject: [PATCH] added local manga, todo: novels --- desktop/package-lock.json | 21 ++++ desktop/package.json | 2 + desktop/src/api/local/local.controller.ts | 114 +++++++++++++++++++- desktop/src/api/local/local.routes.ts | 3 + desktop/src/scripts/books/book.js | 99 ++++++++++++++--- desktop/src/scripts/books/reader.js | 48 ++++++++- desktop/src/scripts/local-library-books.js | 89 ++++++++++++++++ desktop/views/books/book.html | 1 + desktop/views/books/books.html | 38 ++++++- docker/src/api/local/local.controller.ts | 118 ++++++++++++++++++++- docker/src/api/local/local.routes.ts | 3 + docker/src/scripts/books/book.js | 100 ++++++++++++++--- docker/src/scripts/books/reader.js | 48 ++++++++- docker/src/scripts/local-library-books.js | 89 ++++++++++++++++ docker/views/books/book.html | 1 + docker/views/books/books.html | 38 ++++++- 16 files changed, 759 insertions(+), 53 deletions(-) create mode 100644 desktop/src/scripts/local-library-books.js create mode 100644 docker/src/scripts/local-library-books.js diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 202ac24..83f68b3 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@fastify/static": "^8.3.0", "@xhayper/discord-rpc": "^1.3.0", + "adm-zip": "^0.5.16", "bcryptjs": "^3.0.3", "bindings": "^1.5.0", "cheerio": "^1.1.2", @@ -25,6 +26,7 @@ "sqlite3": "^5.1.7" }, "devDependencies": { + "@types/adm-zip": "^0.5.7", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", @@ -1509,6 +1511,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", @@ -1722,6 +1734,15 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", diff --git a/desktop/package.json b/desktop/package.json index da03724..8cce840 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -14,6 +14,7 @@ "dependencies": { "@fastify/static": "^8.3.0", "@xhayper/discord-rpc": "^1.3.0", + "adm-zip": "^0.5.16", "bcryptjs": "^3.0.3", "bindings": "^1.5.0", "cheerio": "^1.1.2", @@ -28,6 +29,7 @@ "sqlite3": "^5.1.7" }, "devDependencies": { + "@types/adm-zip": "^0.5.7", "@types/bcrypt": "^6.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", diff --git a/desktop/src/api/local/local.controller.ts b/desktop/src/api/local/local.controller.ts index 5dbb1cc..c07c070 100644 --- a/desktop/src/api/local/local.controller.ts +++ b/desktop/src/api/local/local.controller.ts @@ -6,7 +6,8 @@ import fs from "fs"; import { PathLike } from "node:fs"; import path from "path"; import {getAnimeById, searchAnimeLocal} from "../anime/anime.service"; -import {getBookById, searchBooksLocal} from "../books/books.service"; +import {getBookById, searchBooksAniList, searchBooksLocal} from "../books/books.service"; +import AdmZip from 'adm-zip'; type SetConfigBody = { library?: { @@ -34,7 +35,7 @@ async function resolveEntryMetadata(entry: any, type: string) { const results = type === 'anime' ? await searchAnimeLocal(query) - : await searchBooksLocal(query); + : await searchBooksAniList(query); const first = results?.[0]; @@ -245,3 +246,112 @@ export async function matchEntry( return { status: 'OK', matched: !!matched_id }; } + +export async function getUnits( + request: FastifyRequest<{ Params: Params }>, + reply: FastifyReply +) { + try { + const { type, id } = request.params as { type: string, id: string }; + + // Buscar la entrada por matched_id + const entry = await queryOne( + `SELECT id, type, matched_id FROM local_entries WHERE matched_id = ? AND type = ?`, + [Number(id), type], + 'local_library' + ); + + if (!entry) { + return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); + } + + // Obtener todos los archivos/unidades ordenados + const files = await queryAll( + `SELECT id, file_path, unit_number FROM local_files + WHERE entry_id = ? + ORDER BY unit_number ASC`, + [entry.id], + 'local_library' + ); + + // Formatear la respuesta según el tipo + const units = files.map((file: any) => { + const fileName = path.basename(file.file_path); + const fileExt = path.extname(file.file_path).toLowerCase(); + + // Detectar si es un archivo comprimido (capítulo único) o carpeta + const isDirectory = fs.existsSync(file.file_path) && + fs.statSync(file.file_path).isDirectory(); + + return { + id: file.id, + number: file.unit_number, + name: fileName, + type: type === 'anime' ? 'episode' : 'chapter', + format: fileExt === '.cbz' ? 'cbz' : 'file', + path: file.file_path + }; + }); + + return { + entry_id: entry.id, + matched_id: entry.matched_id, + type: entry.type, + total: units.length, + units + }; + } catch (err) { + console.error('Error getting units:', err); + return reply.status(500).send({ error: 'FAILED_TO_GET_UNITS' }); + } +} + +export async function getCbzPages(request: FastifyRequest, reply: FastifyReply) { + const { unitId } = request.params as any; + + const file = await queryOne( + `SELECT file_path FROM local_files WHERE id = ?`, + [unitId], + 'local_library' + ); + + if (!file || !fs.existsSync(file.file_path)) { + return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); + } + + const zip = new AdmZip(file.file_path); + + const pages = zip.getEntries() + .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName)) + .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true })) + .map((_, i) => + `/api/library/manga/cbz/${unitId}/page/${i}` + ); + + return { pages }; +} + +export async function getCbzPage(request: FastifyRequest, reply: FastifyReply) { + const { unitId, page } = request.params as any; + + const file = await queryOne( + `SELECT file_path FROM local_files WHERE id = ?`, + [unitId], + 'local_library' + ); + + if (!file) return reply.status(404).send(); + + const zip = new AdmZip(file.file_path); + + const images = zip.getEntries() + .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName)) + .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true })); + + const entry = images[page]; + if (!entry) return reply.status(404).send(); + + reply + .header('Content-Type', 'image/jpeg') + .send(entry.getData()); +} diff --git a/desktop/src/api/local/local.routes.ts b/desktop/src/api/local/local.routes.ts index 5924812..0127890 100644 --- a/desktop/src/api/local/local.routes.ts +++ b/desktop/src/api/local/local.routes.ts @@ -7,6 +7,9 @@ async function localRoutes(fastify: FastifyInstance) { fastify.get('/library/:type/:id', controller.getEntry); fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit); fastify.post('/library/:type/:id/match', controller.matchEntry); + fastify.get('/library/:type/:id/units', controller.getUnits); + fastify.get('/library/:type/cbz/:unitId/pages', controller.getCbzPages); + fastify.get('/library/:type/cbz/:unitId/page/:page', controller.getCbzPage); } export default localRoutes; \ No newline at end of file diff --git a/desktop/src/scripts/books/book.js b/desktop/src/scripts/books/book.js index 2ee4a93..7384528 100644 --- a/desktop/src/scripts/books/book.js +++ b/desktop/src/scripts/books/book.js @@ -7,7 +7,7 @@ let allChapters = []; let filteredChapters = []; let availableExtensions = []; - +let isLocal = false; const chapterPagination = Object.create(PaginationManager); chapterPagination.init(12, () => renderChapterTable()); @@ -16,6 +16,40 @@ document.addEventListener('DOMContentLoaded', () => { setupModalClickOutside(); }); +async function checkLocalLibraryEntry() { + try { + const res = await fetch(`/api/library/manga/${bookId}`); + if (!res.ok) return; + + const data = await res.json(); + if (data.matched) { + isLocal = true; + const pill = document.getElementById('local-pill'); + if (pill) { + pill.textContent = 'Local'; + pill.style.display = 'inline-flex'; + pill.style.background = 'rgba(34, 197, 94, 0.2)'; + pill.style.color = '#22c55e'; + pill.style.borderColor = 'rgba(34, 197, 94, 0.3)'; + } + } + } catch (e) { + console.error("Error checking local status:", e); + } +} + +function markAsLocal() { + isLocal = true; + const pill = document.getElementById('local-pill'); + if (pill) { + pill.textContent = 'Local'; + pill.style.display = 'inline-flex'; + pill.style.background = 'rgba(34, 197, 94, 0.2)'; + pill.style.color = '#22c55e'; + pill.style.borderColor = 'rgba(34, 197, 94, 0.3)'; + } +} + async function init() { try { const urlData = URLUtils.parseEntityPath('book'); @@ -27,7 +61,7 @@ async function init() { extensionName = urlData.extensionName; bookId = urlData.entityId; bookSlug = urlData.slug; - + await checkLocalLibraryEntry(); await loadBookMetadata(); await loadAvailableExtensions(); @@ -173,32 +207,46 @@ async function loadChapters(targetProvider = null) { const tbody = document.getElementById('chapters-body'); if (!tbody) return; - // Si no se pasa provider, intentamos pillar el del select o el primero disponible if (!targetProvider) { const select = document.getElementById('provider-filter'); targetProvider = select ? select.value : (availableExtensions[0] || 'all'); } - tbody.innerHTML = 'Searching extension for chapters...'; + tbody.innerHTML = 'Loading chapters...'; try { - const source = extensionName || 'anilist'; - // Añadimos el query param 'provider' para que el backend filtre - let fetchUrl = `/api/book/${bookId}/chapters?source=${source}`; - if (targetProvider !== 'all') { - fetchUrl += `&provider=${targetProvider}`; + let fetchUrl; + let isLocalRequest = targetProvider === 'local'; + + if (isLocalRequest) { + // Nuevo endpoint para archivos locales + fetchUrl = `/api/library/manga/${bookId}/units`; + } else { + const source = extensionName || 'anilist'; + fetchUrl = `/api/book/${bookId}/chapters?source=${source}`; + if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`; } const res = await fetch(fetchUrl); const data = await res.json(); - allChapters = data.chapters || []; - filteredChapters = [...allChapters]; + // Mapeo de datos: Si es local usamos 'units', si no, usamos 'chapters' + if (isLocalRequest) { + allChapters = (data.units || []).map((unit, idx) => ({ + number: unit.number, + title: unit.name, + provider: 'local', + index: idx, // ✅ índice (0,1,2…) + format: unit.format + })); + } else { + allChapters = data.chapters || []; + } + filteredChapters = [...allChapters]; applyChapterFilter(); const totalEl = document.getElementById('total-chapters'); - if (allChapters.length === 0) { tbody.innerHTML = 'No chapters found.'; if (totalEl) totalEl.innerText = "0 Found"; @@ -208,7 +256,6 @@ async function loadChapters(targetProvider = null) { if (totalEl) totalEl.innerText = `${allChapters.length} Found`; setupReadButton(); - chapterPagination.setTotalItems(filteredChapters.length); chapterPagination.reset(); renderChapterTable(); @@ -235,16 +282,26 @@ function applyChapterFilter() { function setupProviderFilter() { const select = document.getElementById('provider-filter'); - if (!select || availableExtensions.length === 0) return; + if (!select) return; select.style.display = 'inline-block'; select.innerHTML = ''; + // Opción para cargar todo const allOpt = document.createElement('option'); allOpt.value = 'all'; allOpt.innerText = 'Load All (Slower)'; select.appendChild(allOpt); + // NUEVO: Si es local, añadimos la opción 'local' al principio + if (isLocal) { + const localOpt = document.createElement('option'); + localOpt.value = 'local'; + localOpt.innerText = 'Local'; + select.appendChild(localOpt); + } + + // Añadir extensiones normales availableExtensions.forEach(ext => { const opt = document.createElement('option'); opt.value = ext; @@ -252,7 +309,10 @@ function setupProviderFilter() { select.appendChild(opt); }); - if (extensionName && availableExtensions.includes(extensionName)) { + // Lógica de selección automática + if (isLocal) { + select.value = 'local'; // Prioridad si es local + } else if (extensionName && availableExtensions.includes(extensionName)) { select.value = extensionName; } else if (availableExtensions.length > 0) { select.value = availableExtensions[0]; @@ -314,7 +374,14 @@ function renderChapterTable() { } function openReader(chapterId, provider) { - window.location.href = URLUtils.buildReadUrl(bookId, chapterId, provider, extensionName); + const effectiveExtension = extensionName || 'anilist'; + + window.location.href = URLUtils.buildReadUrl( + bookId, // SIEMPRE anilist + chapterId, // número normal + provider, // 'local' o extensión + extensionName || 'anilist' + ); } function setupModalClickOutside() { diff --git a/desktop/src/scripts/books/reader.js b/desktop/src/scripts/books/reader.js index 5733f02..05d19d1 100644 --- a/desktop/src/scripts/books/reader.js +++ b/desktop/src/scripts/books/reader.js @@ -129,11 +129,44 @@ async function loadChapter() { if (!source) { source = 'anilist'; } - const newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`; + let newEndpoint; + + if (provider === 'local') { + newEndpoint = `/api/library/manga/${bookId}/units`; + } else { + newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`; + } try { const res = await fetch(newEndpoint); const data = await res.json(); + if (provider === 'local') { + const unit = data.units[Number(chapter)]; + + if (!unit) { + reader.innerHTML = '
Chapter not found
'; + return; + } + + if (unit.format === 'cbz') { + chapterLabel.textContent = unit.name; // ✅ + document.title = unit.name; + const pagesRes = await fetch( + `/api/library/manga/cbz/${unit.id}/pages` + ); + const pagesData = await pagesRes.json(); + + currentType = 'manga'; + updateSettingsVisibility(); + applyStyles(); + + currentPages = pagesData.pages.map(url => ({ url })); + reader.innerHTML = ''; + loadManga(currentPages); + return; + } + } + if (data.title) { chapterLabel.textContent = data.title; @@ -172,8 +205,13 @@ async function loadChapter() { reader.innerHTML = ''; if (data.type === 'manga') { - currentPages = data.pages || []; - loadManga(currentPages); + if (provider === 'local' && data.format === 'cbz') { + currentPages = data.pages.map(url => ({ url })); + loadManga(currentPages); + } else { + currentPages = data.pages || []; + loadManga(currentPages); + } } else if (data.type === 'ln') { loadLN(data.content); } @@ -293,7 +331,9 @@ function createImageElement(page, index) { img.className = 'page-img'; img.dataset.index = index; - const url = buildProxyUrl(page.url, page.headers); + const url = provider === 'local' + ? page.url + : buildProxyUrl(page.url, page.headers); const placeholder = "/public/assets/placeholder.svg"; img.onerror = () => { diff --git a/desktop/src/scripts/local-library-books.js b/desktop/src/scripts/local-library-books.js new file mode 100644 index 0000000..63e3f62 --- /dev/null +++ b/desktop/src/scripts/local-library-books.js @@ -0,0 +1,89 @@ +let activeFilter = 'all'; +let activeSort = 'az'; +let isLocalMode = false; +let localEntries = []; + +function toggleLibraryMode() { + isLocalMode = !isLocalMode; + const btn = document.getElementById('library-mode-btn'); + const onlineContent = document.getElementById('online-content'); + const localContent = document.getElementById('local-content'); + + if (isLocalMode) { + btn.classList.add('active'); + onlineContent.classList.add('hidden'); + localContent.classList.remove('hidden'); + loadLocalEntries(); + } else { + btn.classList.remove('active'); + onlineContent.classList.remove('hidden'); + localContent.classList.add('hidden'); + } +} + +async function loadLocalEntries() { + const grid = document.getElementById('local-entries-grid'); + grid.innerHTML = '
'.repeat(6); + + try { + // Cambiado a endpoint de libros + const response = await fetch('/api/library/manga'); + const entries = await response.json(); + localEntries = entries; + + if (entries.length === 0) { + grid.innerHTML = '

No books found in your local library.

'; + return; + } + renderLocalEntries(entries); + } catch (err) { + grid.innerHTML = '

Error loading local books.

'; + } +} + +function renderLocalEntries(entries) { + const grid = document.getElementById('local-entries-grid'); + grid.innerHTML = entries.map(entry => { + const title = entry.metadata?.title?.romaji || entry.metadata?.title?.english || entry.id; + const cover = entry.metadata?.coverImage?.extraLarge || '/public/assets/placeholder.jpg'; + const chapters = entry.metadata?.chapters || '??'; + + return ` +
+
+ ${title} +
+
+
${title}
+

+ ${chapters} Chapters +

+
+ ${entry.matched ? '● Linked' : '○ Unlinked'} +
+
+
+ `; + }).join(''); +} + +async function scanLocalLibrary() { + const btnText = document.getElementById('scan-text'); + btnText.innerText = "Scanning..."; + try { + // Asumiendo que el scan de libros usa este query param + const response = await fetch('/api/library/scan?mode=incremental', { method: 'POST' }); + if (response.ok) { + await loadLocalEntries(); + if (window.NotificationUtils) NotificationUtils.show('Library scanned!', 'success'); + } + } catch (err) { + if (window.NotificationUtils) NotificationUtils.show('Scan failed', 'error'); + } finally { + btnText.innerText = "Scan Library"; + } +} + +function viewLocalEntry(id) { + if (id) window.location.href = `/book/${id}`; +} \ No newline at end of file diff --git a/desktop/views/books/book.html b/desktop/views/books/book.html index f26b48e..a784508 100644 --- a/desktop/views/books/book.html +++ b/desktop/views/books/book.html @@ -74,6 +74,7 @@
+
--% Score
Action
diff --git a/desktop/views/books/books.html b/desktop/views/books/books.html index 46c9ab5..890ae45 100644 --- a/desktop/views/books/books.html +++ b/desktop/views/books/books.html @@ -12,6 +12,7 @@ +
@@ -49,9 +50,40 @@
+ - -
+
+
+
+
Local Books Library
+ +
+
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
Continue Reading
@@ -100,7 +132,7 @@ - + diff --git a/docker/src/api/local/local.controller.ts b/docker/src/api/local/local.controller.ts index 5c66a44..c07c070 100644 --- a/docker/src/api/local/local.controller.ts +++ b/docker/src/api/local/local.controller.ts @@ -6,7 +6,8 @@ import fs from "fs"; import { PathLike } from "node:fs"; import path from "path"; import {getAnimeById, searchAnimeLocal} from "../anime/anime.service"; -import {getBookById, searchBooksLocal} from "../books/books.service"; +import {getBookById, searchBooksAniList, searchBooksLocal} from "../books/books.service"; +import AdmZip from 'adm-zip'; type SetConfigBody = { library?: { @@ -34,7 +35,7 @@ async function resolveEntryMetadata(entry: any, type: string) { const results = type === 'anime' ? await searchAnimeLocal(query) - : await searchBooksLocal(query); + : await searchBooksAniList(query); const first = results?.[0]; @@ -237,11 +238,120 @@ export async function matchEntry( await run( `UPDATE local_entries - SET matched_source = ?, matched_id = ? - WHERE id = ?`, + SET matched_source = ?, matched_id = ? + WHERE id = ?`, [source, matched_id, id], 'local_library' ); return { status: 'OK', matched: !!matched_id }; } + +export async function getUnits( + request: FastifyRequest<{ Params: Params }>, + reply: FastifyReply +) { + try { + const { type, id } = request.params as { type: string, id: string }; + + // Buscar la entrada por matched_id + const entry = await queryOne( + `SELECT id, type, matched_id FROM local_entries WHERE matched_id = ? AND type = ?`, + [Number(id), type], + 'local_library' + ); + + if (!entry) { + return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); + } + + // Obtener todos los archivos/unidades ordenados + const files = await queryAll( + `SELECT id, file_path, unit_number FROM local_files + WHERE entry_id = ? + ORDER BY unit_number ASC`, + [entry.id], + 'local_library' + ); + + // Formatear la respuesta según el tipo + const units = files.map((file: any) => { + const fileName = path.basename(file.file_path); + const fileExt = path.extname(file.file_path).toLowerCase(); + + // Detectar si es un archivo comprimido (capítulo único) o carpeta + const isDirectory = fs.existsSync(file.file_path) && + fs.statSync(file.file_path).isDirectory(); + + return { + id: file.id, + number: file.unit_number, + name: fileName, + type: type === 'anime' ? 'episode' : 'chapter', + format: fileExt === '.cbz' ? 'cbz' : 'file', + path: file.file_path + }; + }); + + return { + entry_id: entry.id, + matched_id: entry.matched_id, + type: entry.type, + total: units.length, + units + }; + } catch (err) { + console.error('Error getting units:', err); + return reply.status(500).send({ error: 'FAILED_TO_GET_UNITS' }); + } +} + +export async function getCbzPages(request: FastifyRequest, reply: FastifyReply) { + const { unitId } = request.params as any; + + const file = await queryOne( + `SELECT file_path FROM local_files WHERE id = ?`, + [unitId], + 'local_library' + ); + + if (!file || !fs.existsSync(file.file_path)) { + return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); + } + + const zip = new AdmZip(file.file_path); + + const pages = zip.getEntries() + .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName)) + .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true })) + .map((_, i) => + `/api/library/manga/cbz/${unitId}/page/${i}` + ); + + return { pages }; +} + +export async function getCbzPage(request: FastifyRequest, reply: FastifyReply) { + const { unitId, page } = request.params as any; + + const file = await queryOne( + `SELECT file_path FROM local_files WHERE id = ?`, + [unitId], + 'local_library' + ); + + if (!file) return reply.status(404).send(); + + const zip = new AdmZip(file.file_path); + + const images = zip.getEntries() + .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName)) + .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true })); + + const entry = images[page]; + if (!entry) return reply.status(404).send(); + + reply + .header('Content-Type', 'image/jpeg') + .send(entry.getData()); +} diff --git a/docker/src/api/local/local.routes.ts b/docker/src/api/local/local.routes.ts index 5924812..0127890 100644 --- a/docker/src/api/local/local.routes.ts +++ b/docker/src/api/local/local.routes.ts @@ -7,6 +7,9 @@ async function localRoutes(fastify: FastifyInstance) { fastify.get('/library/:type/:id', controller.getEntry); fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit); fastify.post('/library/:type/:id/match', controller.matchEntry); + fastify.get('/library/:type/:id/units', controller.getUnits); + fastify.get('/library/:type/cbz/:unitId/pages', controller.getCbzPages); + fastify.get('/library/:type/cbz/:unitId/page/:page', controller.getCbzPage); } export default localRoutes; \ No newline at end of file diff --git a/docker/src/scripts/books/book.js b/docker/src/scripts/books/book.js index 7b39a06..7384528 100644 --- a/docker/src/scripts/books/book.js +++ b/docker/src/scripts/books/book.js @@ -7,7 +7,7 @@ let allChapters = []; let filteredChapters = []; let availableExtensions = []; - +let isLocal = false; const chapterPagination = Object.create(PaginationManager); chapterPagination.init(12, () => renderChapterTable()); @@ -16,6 +16,40 @@ document.addEventListener('DOMContentLoaded', () => { setupModalClickOutside(); }); +async function checkLocalLibraryEntry() { + try { + const res = await fetch(`/api/library/manga/${bookId}`); + if (!res.ok) return; + + const data = await res.json(); + if (data.matched) { + isLocal = true; + const pill = document.getElementById('local-pill'); + if (pill) { + pill.textContent = 'Local'; + pill.style.display = 'inline-flex'; + pill.style.background = 'rgba(34, 197, 94, 0.2)'; + pill.style.color = '#22c55e'; + pill.style.borderColor = 'rgba(34, 197, 94, 0.3)'; + } + } + } catch (e) { + console.error("Error checking local status:", e); + } +} + +function markAsLocal() { + isLocal = true; + const pill = document.getElementById('local-pill'); + if (pill) { + pill.textContent = 'Local'; + pill.style.display = 'inline-flex'; + pill.style.background = 'rgba(34, 197, 94, 0.2)'; + pill.style.color = '#22c55e'; + pill.style.borderColor = 'rgba(34, 197, 94, 0.3)'; + } +} + async function init() { try { const urlData = URLUtils.parseEntityPath('book'); @@ -27,7 +61,7 @@ async function init() { extensionName = urlData.extensionName; bookId = urlData.entityId; bookSlug = urlData.slug; - + await checkLocalLibraryEntry(); await loadBookMetadata(); await loadAvailableExtensions(); @@ -71,7 +105,6 @@ async function loadBookMetadata() { const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName); bookData.entry_type = metadata.format === 'MANGA' ? 'MANGA' : 'NOVEL'; - updatePageTitle(metadata.title); updateMetadata(metadata); updateExtensionPill(); @@ -174,32 +207,46 @@ async function loadChapters(targetProvider = null) { const tbody = document.getElementById('chapters-body'); if (!tbody) return; - // Si no se pasa provider, intentamos pillar el del select o el primero disponible if (!targetProvider) { const select = document.getElementById('provider-filter'); targetProvider = select ? select.value : (availableExtensions[0] || 'all'); } - tbody.innerHTML = 'Searching extension for chapters...'; + tbody.innerHTML = 'Loading chapters...'; try { - const source = extensionName || 'anilist'; - // Añadimos el query param 'provider' para que el backend filtre - let fetchUrl = `/api/book/${bookId}/chapters?source=${source}`; - if (targetProvider !== 'all') { - fetchUrl += `&provider=${targetProvider}`; + let fetchUrl; + let isLocalRequest = targetProvider === 'local'; + + if (isLocalRequest) { + // Nuevo endpoint para archivos locales + fetchUrl = `/api/library/manga/${bookId}/units`; + } else { + const source = extensionName || 'anilist'; + fetchUrl = `/api/book/${bookId}/chapters?source=${source}`; + if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`; } const res = await fetch(fetchUrl); const data = await res.json(); - allChapters = data.chapters || []; - filteredChapters = [...allChapters]; + // Mapeo de datos: Si es local usamos 'units', si no, usamos 'chapters' + if (isLocalRequest) { + allChapters = (data.units || []).map((unit, idx) => ({ + number: unit.number, + title: unit.name, + provider: 'local', + index: idx, // ✅ índice (0,1,2…) + format: unit.format + })); + } else { + allChapters = data.chapters || []; + } + filteredChapters = [...allChapters]; applyChapterFilter(); const totalEl = document.getElementById('total-chapters'); - if (allChapters.length === 0) { tbody.innerHTML = 'No chapters found.'; if (totalEl) totalEl.innerText = "0 Found"; @@ -209,7 +256,6 @@ async function loadChapters(targetProvider = null) { if (totalEl) totalEl.innerText = `${allChapters.length} Found`; setupReadButton(); - chapterPagination.setTotalItems(filteredChapters.length); chapterPagination.reset(); renderChapterTable(); @@ -236,16 +282,26 @@ function applyChapterFilter() { function setupProviderFilter() { const select = document.getElementById('provider-filter'); - if (!select || availableExtensions.length === 0) return; + if (!select) return; select.style.display = 'inline-block'; select.innerHTML = ''; + // Opción para cargar todo const allOpt = document.createElement('option'); allOpt.value = 'all'; allOpt.innerText = 'Load All (Slower)'; select.appendChild(allOpt); + // NUEVO: Si es local, añadimos la opción 'local' al principio + if (isLocal) { + const localOpt = document.createElement('option'); + localOpt.value = 'local'; + localOpt.innerText = 'Local'; + select.appendChild(localOpt); + } + + // Añadir extensiones normales availableExtensions.forEach(ext => { const opt = document.createElement('option'); opt.value = ext; @@ -253,7 +309,10 @@ function setupProviderFilter() { select.appendChild(opt); }); - if (extensionName && availableExtensions.includes(extensionName)) { + // Lógica de selección automática + if (isLocal) { + select.value = 'local'; // Prioridad si es local + } else if (extensionName && availableExtensions.includes(extensionName)) { select.value = extensionName; } else if (availableExtensions.length > 0) { select.value = availableExtensions[0]; @@ -315,7 +374,14 @@ function renderChapterTable() { } function openReader(chapterId, provider) { - window.location.href = URLUtils.buildReadUrl(bookId, chapterId, provider, extensionName); + const effectiveExtension = extensionName || 'anilist'; + + window.location.href = URLUtils.buildReadUrl( + bookId, // SIEMPRE anilist + chapterId, // número normal + provider, // 'local' o extensión + extensionName || 'anilist' + ); } function setupModalClickOutside() { diff --git a/docker/src/scripts/books/reader.js b/docker/src/scripts/books/reader.js index a323e0e..f0ba41d 100644 --- a/docker/src/scripts/books/reader.js +++ b/docker/src/scripts/books/reader.js @@ -129,11 +129,44 @@ async function loadChapter() { if (!source) { source = 'anilist'; } - const newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`; + let newEndpoint; + + if (provider === 'local') { + newEndpoint = `/api/library/manga/${bookId}/units`; + } else { + newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`; + } try { const res = await fetch(newEndpoint); const data = await res.json(); + if (provider === 'local') { + const unit = data.units[Number(chapter)]; + + if (!unit) { + reader.innerHTML = '
Chapter not found
'; + return; + } + + if (unit.format === 'cbz') { + chapterLabel.textContent = unit.name; // ✅ + document.title = unit.name; + const pagesRes = await fetch( + `/api/library/manga/cbz/${unit.id}/pages` + ); + const pagesData = await pagesRes.json(); + + currentType = 'manga'; + updateSettingsVisibility(); + applyStyles(); + + currentPages = pagesData.pages.map(url => ({ url })); + reader.innerHTML = ''; + loadManga(currentPages); + return; + } + } + if (data.title) { chapterLabel.textContent = data.title; @@ -160,8 +193,13 @@ async function loadChapter() { reader.innerHTML = ''; if (data.type === 'manga') { - currentPages = data.pages || []; - loadManga(currentPages); + if (provider === 'local' && data.format === 'cbz') { + currentPages = data.pages.map(url => ({ url })); + loadManga(currentPages); + } else { + currentPages = data.pages || []; + loadManga(currentPages); + } } else if (data.type === 'ln') { loadLN(data.content); } @@ -281,7 +319,9 @@ function createImageElement(page, index) { img.className = 'page-img'; img.dataset.index = index; - const url = buildProxyUrl(page.url, page.headers); + const url = provider === 'local' + ? page.url + : buildProxyUrl(page.url, page.headers); const placeholder = "/public/assets/placeholder.svg"; img.onerror = () => { diff --git a/docker/src/scripts/local-library-books.js b/docker/src/scripts/local-library-books.js new file mode 100644 index 0000000..63e3f62 --- /dev/null +++ b/docker/src/scripts/local-library-books.js @@ -0,0 +1,89 @@ +let activeFilter = 'all'; +let activeSort = 'az'; +let isLocalMode = false; +let localEntries = []; + +function toggleLibraryMode() { + isLocalMode = !isLocalMode; + const btn = document.getElementById('library-mode-btn'); + const onlineContent = document.getElementById('online-content'); + const localContent = document.getElementById('local-content'); + + if (isLocalMode) { + btn.classList.add('active'); + onlineContent.classList.add('hidden'); + localContent.classList.remove('hidden'); + loadLocalEntries(); + } else { + btn.classList.remove('active'); + onlineContent.classList.remove('hidden'); + localContent.classList.add('hidden'); + } +} + +async function loadLocalEntries() { + const grid = document.getElementById('local-entries-grid'); + grid.innerHTML = '
'.repeat(6); + + try { + // Cambiado a endpoint de libros + const response = await fetch('/api/library/manga'); + const entries = await response.json(); + localEntries = entries; + + if (entries.length === 0) { + grid.innerHTML = '

No books found in your local library.

'; + return; + } + renderLocalEntries(entries); + } catch (err) { + grid.innerHTML = '

Error loading local books.

'; + } +} + +function renderLocalEntries(entries) { + const grid = document.getElementById('local-entries-grid'); + grid.innerHTML = entries.map(entry => { + const title = entry.metadata?.title?.romaji || entry.metadata?.title?.english || entry.id; + const cover = entry.metadata?.coverImage?.extraLarge || '/public/assets/placeholder.jpg'; + const chapters = entry.metadata?.chapters || '??'; + + return ` +
+
+ ${title} +
+
+
${title}
+

+ ${chapters} Chapters +

+
+ ${entry.matched ? '● Linked' : '○ Unlinked'} +
+
+
+ `; + }).join(''); +} + +async function scanLocalLibrary() { + const btnText = document.getElementById('scan-text'); + btnText.innerText = "Scanning..."; + try { + // Asumiendo que el scan de libros usa este query param + const response = await fetch('/api/library/scan?mode=incremental', { method: 'POST' }); + if (response.ok) { + await loadLocalEntries(); + if (window.NotificationUtils) NotificationUtils.show('Library scanned!', 'success'); + } + } catch (err) { + if (window.NotificationUtils) NotificationUtils.show('Scan failed', 'error'); + } finally { + btnText.innerText = "Scan Library"; + } +} + +function viewLocalEntry(id) { + if (id) window.location.href = `/book/${id}`; +} \ No newline at end of file diff --git a/docker/views/books/book.html b/docker/views/books/book.html index 67cd99f..7140a2b 100644 --- a/docker/views/books/book.html +++ b/docker/views/books/book.html @@ -62,6 +62,7 @@
+
--% Score
Action
diff --git a/docker/views/books/books.html b/docker/views/books/books.html index c291843..061dd8a 100644 --- a/docker/views/books/books.html +++ b/docker/views/books/books.html @@ -9,6 +9,7 @@ + @@ -36,9 +37,40 @@
+ - -
+
+
+
+
Local Books Library
+ +
+
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
Continue Reading
@@ -87,7 +119,7 @@ - +