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}
+
+ ${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 @@
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 @@
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+