added local manga, todo: novels

This commit is contained in:
2025-12-27 21:59:03 +01:00
parent 295cab93f3
commit d49f739565
16 changed files with 759 additions and 53 deletions

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@fastify/static": "^8.3.0", "@fastify/static": "^8.3.0",
"@xhayper/discord-rpc": "^1.3.0", "@xhayper/discord-rpc": "^1.3.0",
"adm-zip": "^0.5.16",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"bindings": "^1.5.0", "bindings": "^1.5.0",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
@@ -25,6 +26,7 @@
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
@@ -1509,6 +1511,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/bcrypt": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
@@ -1722,6 +1734,15 @@
"node": ">=0.4.0" "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": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",

View File

@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@fastify/static": "^8.3.0", "@fastify/static": "^8.3.0",
"@xhayper/discord-rpc": "^1.3.0", "@xhayper/discord-rpc": "^1.3.0",
"adm-zip": "^0.5.16",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"bindings": "^1.5.0", "bindings": "^1.5.0",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
@@ -28,6 +29,7 @@
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",

View File

@@ -6,7 +6,8 @@ import fs from "fs";
import { PathLike } from "node:fs"; import { PathLike } from "node:fs";
import path from "path"; import path from "path";
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service"; 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 = { type SetConfigBody = {
library?: { library?: {
@@ -34,7 +35,7 @@ async function resolveEntryMetadata(entry: any, type: string) {
const results = type === 'anime' const results = type === 'anime'
? await searchAnimeLocal(query) ? await searchAnimeLocal(query)
: await searchBooksLocal(query); : await searchBooksAniList(query);
const first = results?.[0]; const first = results?.[0];
@@ -245,3 +246,112 @@ export async function matchEntry(
return { status: 'OK', matched: !!matched_id }; 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());
}

View File

@@ -7,6 +7,9 @@ async function localRoutes(fastify: FastifyInstance) {
fastify.get('/library/:type/:id', controller.getEntry); fastify.get('/library/:type/:id', controller.getEntry);
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit); fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
fastify.post('/library/:type/:id/match', controller.matchEntry); 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; export default localRoutes;

View File

@@ -7,7 +7,7 @@ let allChapters = [];
let filteredChapters = []; let filteredChapters = [];
let availableExtensions = []; let availableExtensions = [];
let isLocal = false;
const chapterPagination = Object.create(PaginationManager); const chapterPagination = Object.create(PaginationManager);
chapterPagination.init(12, () => renderChapterTable()); chapterPagination.init(12, () => renderChapterTable());
@@ -16,6 +16,40 @@ document.addEventListener('DOMContentLoaded', () => {
setupModalClickOutside(); 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() { async function init() {
try { try {
const urlData = URLUtils.parseEntityPath('book'); const urlData = URLUtils.parseEntityPath('book');
@@ -27,7 +61,7 @@ async function init() {
extensionName = urlData.extensionName; extensionName = urlData.extensionName;
bookId = urlData.entityId; bookId = urlData.entityId;
bookSlug = urlData.slug; bookSlug = urlData.slug;
await checkLocalLibraryEntry();
await loadBookMetadata(); await loadBookMetadata();
await loadAvailableExtensions(); await loadAvailableExtensions();
@@ -173,32 +207,46 @@ async function loadChapters(targetProvider = null) {
const tbody = document.getElementById('chapters-body'); const tbody = document.getElementById('chapters-body');
if (!tbody) return; if (!tbody) return;
// Si no se pasa provider, intentamos pillar el del select o el primero disponible
if (!targetProvider) { if (!targetProvider) {
const select = document.getElementById('provider-filter'); const select = document.getElementById('provider-filter');
targetProvider = select ? select.value : (availableExtensions[0] || 'all'); targetProvider = select ? select.value : (availableExtensions[0] || 'all');
} }
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Searching extension for chapters...</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Loading chapters...</td></tr>';
try { try {
let fetchUrl;
let isLocalRequest = targetProvider === 'local';
if (isLocalRequest) {
// Nuevo endpoint para archivos locales
fetchUrl = `/api/library/manga/${bookId}/units`;
} else {
const source = extensionName || 'anilist'; const source = extensionName || 'anilist';
// Añadimos el query param 'provider' para que el backend filtre fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
let fetchUrl = `/api/book/${bookId}/chapters?source=${source}`; if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`;
if (targetProvider !== 'all') {
fetchUrl += `&provider=${targetProvider}`;
} }
const res = await fetch(fetchUrl); const res = await fetch(fetchUrl);
const data = await res.json(); const data = await res.json();
// 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 || []; allChapters = data.chapters || [];
filteredChapters = [...allChapters]; }
filteredChapters = [...allChapters];
applyChapterFilter(); applyChapterFilter();
const totalEl = document.getElementById('total-chapters'); const totalEl = document.getElementById('total-chapters');
if (allChapters.length === 0) { if (allChapters.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>';
if (totalEl) totalEl.innerText = "0 Found"; if (totalEl) totalEl.innerText = "0 Found";
@@ -208,7 +256,6 @@ async function loadChapters(targetProvider = null) {
if (totalEl) totalEl.innerText = `${allChapters.length} Found`; if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
setupReadButton(); setupReadButton();
chapterPagination.setTotalItems(filteredChapters.length); chapterPagination.setTotalItems(filteredChapters.length);
chapterPagination.reset(); chapterPagination.reset();
renderChapterTable(); renderChapterTable();
@@ -235,16 +282,26 @@ function applyChapterFilter() {
function setupProviderFilter() { function setupProviderFilter() {
const select = document.getElementById('provider-filter'); const select = document.getElementById('provider-filter');
if (!select || availableExtensions.length === 0) return; if (!select) return;
select.style.display = 'inline-block'; select.style.display = 'inline-block';
select.innerHTML = ''; select.innerHTML = '';
// Opción para cargar todo
const allOpt = document.createElement('option'); const allOpt = document.createElement('option');
allOpt.value = 'all'; allOpt.value = 'all';
allOpt.innerText = 'Load All (Slower)'; allOpt.innerText = 'Load All (Slower)';
select.appendChild(allOpt); 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 => { availableExtensions.forEach(ext => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = ext; opt.value = ext;
@@ -252,7 +309,10 @@ function setupProviderFilter() {
select.appendChild(opt); 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; select.value = extensionName;
} else if (availableExtensions.length > 0) { } else if (availableExtensions.length > 0) {
select.value = availableExtensions[0]; select.value = availableExtensions[0];
@@ -314,7 +374,14 @@ function renderChapterTable() {
} }
function openReader(chapterId, provider) { 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() { function setupModalClickOutside() {

View File

@@ -129,11 +129,44 @@ async function loadChapter() {
if (!source) { if (!source) {
source = 'anilist'; 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 { try {
const res = await fetch(newEndpoint); const res = await fetch(newEndpoint);
const data = await res.json(); const data = await res.json();
if (provider === 'local') {
const unit = data.units[Number(chapter)];
if (!unit) {
reader.innerHTML = '<div class="loading-container"><span>Chapter not found</span></div>';
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) { if (data.title) {
chapterLabel.textContent = data.title; chapterLabel.textContent = data.title;
@@ -172,8 +205,13 @@ async function loadChapter() {
reader.innerHTML = ''; reader.innerHTML = '';
if (data.type === 'manga') { if (data.type === 'manga') {
if (provider === 'local' && data.format === 'cbz') {
currentPages = data.pages.map(url => ({ url }));
loadManga(currentPages);
} else {
currentPages = data.pages || []; currentPages = data.pages || [];
loadManga(currentPages); loadManga(currentPages);
}
} else if (data.type === 'ln') { } else if (data.type === 'ln') {
loadLN(data.content); loadLN(data.content);
} }
@@ -293,7 +331,9 @@ function createImageElement(page, index) {
img.className = 'page-img'; img.className = 'page-img';
img.dataset.index = index; 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"; const placeholder = "/public/assets/placeholder.svg";
img.onerror = () => { img.onerror = () => {

View File

@@ -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 = '<div class="skeleton-card"></div>'.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 = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 3rem;">No books found in your local library.</p>';
return;
}
renderLocalEntries(entries);
} catch (err) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-danger); padding: 3rem;">Error loading local books.</p>';
}
}
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 `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${chapters} Chapters
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).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}`;
}

View File

@@ -74,6 +74,7 @@
<div class="meta-row"> <div class="meta-row">
<div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div> <div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill" id="local-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill score" id="score">--% Score</div> <div class="pill score" id="score">--% Score</div>
<div class="pill" id="genres">Action</div> <div class="pill" id="genres">Action</div>
</div> </div>

View File

@@ -12,6 +12,7 @@
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon"> <link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
<script src="/src/scripts/titlebar.js"></script> <script src="/src/scripts/titlebar.js"></script>
<link rel="stylesheet" href="/views/css/components/local-library.css">
</head> </head>
<body> <body>
<div id="titlebar"> <div class="title-left"> <div id="titlebar"> <div class="title-left">
@@ -49,9 +50,40 @@
</div> </div>
</div> </div>
</div> </div>
<button class="library-mode-btn icon-only" id="library-mode-btn" onclick="toggleLibraryMode()" title="Switch library mode">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</button>
</div> </div>
<main id="local-content" class="hidden">
<main> <section class="section">
<div class="section-header">
<div class="section-title">Local Books Library</div>
<button class="btn-secondary" onclick="scanLocalLibrary()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9"/><path d="M21 3v6h-6"/>
</svg>
<span id="scan-text">Scan Library</span>
</button>
</div>
<div class="local-filters">
<div class="filter-group">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
</div>
<div class="filter-group">
<button class="filter-btn" data-sort="az">AZ</button>
</div>
</div>
<div class="local-library-grid" id="local-entries-grid">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
</section>
</main>
<main id="online-content">
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
<div class="section-title">Continue Reading</div> <div class="section-title">Continue Reading</div>
@@ -100,7 +132,7 @@
<script src="/src/scripts/utils/list-modal-manager.js"></script> <script src="/src/scripts/utils/list-modal-manager.js"></script>
<script src="/src/scripts/utils/continue-watching-manager.js"></script> <script src="/src/scripts/utils/continue-watching-manager.js"></script>
<script src="/src/scripts/books/books.js"></script> <script src="/src/scripts/books/books.js"></script>
<script src="/src/scripts/local-library-books.js"></script>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script> <script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>

View File

@@ -6,7 +6,8 @@ import fs from "fs";
import { PathLike } from "node:fs"; import { PathLike } from "node:fs";
import path from "path"; import path from "path";
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service"; 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 = { type SetConfigBody = {
library?: { library?: {
@@ -34,7 +35,7 @@ async function resolveEntryMetadata(entry: any, type: string) {
const results = type === 'anime' const results = type === 'anime'
? await searchAnimeLocal(query) ? await searchAnimeLocal(query)
: await searchBooksLocal(query); : await searchBooksAniList(query);
const first = results?.[0]; const first = results?.[0];
@@ -245,3 +246,112 @@ export async function matchEntry(
return { status: 'OK', matched: !!matched_id }; 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());
}

View File

@@ -7,6 +7,9 @@ async function localRoutes(fastify: FastifyInstance) {
fastify.get('/library/:type/:id', controller.getEntry); fastify.get('/library/:type/:id', controller.getEntry);
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit); fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
fastify.post('/library/:type/:id/match', controller.matchEntry); 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; export default localRoutes;

View File

@@ -7,7 +7,7 @@ let allChapters = [];
let filteredChapters = []; let filteredChapters = [];
let availableExtensions = []; let availableExtensions = [];
let isLocal = false;
const chapterPagination = Object.create(PaginationManager); const chapterPagination = Object.create(PaginationManager);
chapterPagination.init(12, () => renderChapterTable()); chapterPagination.init(12, () => renderChapterTable());
@@ -16,6 +16,40 @@ document.addEventListener('DOMContentLoaded', () => {
setupModalClickOutside(); 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() { async function init() {
try { try {
const urlData = URLUtils.parseEntityPath('book'); const urlData = URLUtils.parseEntityPath('book');
@@ -27,7 +61,7 @@ async function init() {
extensionName = urlData.extensionName; extensionName = urlData.extensionName;
bookId = urlData.entityId; bookId = urlData.entityId;
bookSlug = urlData.slug; bookSlug = urlData.slug;
await checkLocalLibraryEntry();
await loadBookMetadata(); await loadBookMetadata();
await loadAvailableExtensions(); await loadAvailableExtensions();
@@ -71,7 +105,6 @@ async function loadBookMetadata() {
const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName); const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName);
bookData.entry_type = bookData.entry_type =
metadata.format === 'MANGA' ? 'MANGA' : 'NOVEL'; metadata.format === 'MANGA' ? 'MANGA' : 'NOVEL';
updatePageTitle(metadata.title); updatePageTitle(metadata.title);
updateMetadata(metadata); updateMetadata(metadata);
updateExtensionPill(); updateExtensionPill();
@@ -174,32 +207,46 @@ async function loadChapters(targetProvider = null) {
const tbody = document.getElementById('chapters-body'); const tbody = document.getElementById('chapters-body');
if (!tbody) return; if (!tbody) return;
// Si no se pasa provider, intentamos pillar el del select o el primero disponible
if (!targetProvider) { if (!targetProvider) {
const select = document.getElementById('provider-filter'); const select = document.getElementById('provider-filter');
targetProvider = select ? select.value : (availableExtensions[0] || 'all'); targetProvider = select ? select.value : (availableExtensions[0] || 'all');
} }
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Searching extension for chapters...</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Loading chapters...</td></tr>';
try { try {
let fetchUrl;
let isLocalRequest = targetProvider === 'local';
if (isLocalRequest) {
// Nuevo endpoint para archivos locales
fetchUrl = `/api/library/manga/${bookId}/units`;
} else {
const source = extensionName || 'anilist'; const source = extensionName || 'anilist';
// Añadimos el query param 'provider' para que el backend filtre fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
let fetchUrl = `/api/book/${bookId}/chapters?source=${source}`; if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`;
if (targetProvider !== 'all') {
fetchUrl += `&provider=${targetProvider}`;
} }
const res = await fetch(fetchUrl); const res = await fetch(fetchUrl);
const data = await res.json(); const data = await res.json();
// 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 || []; allChapters = data.chapters || [];
filteredChapters = [...allChapters]; }
filteredChapters = [...allChapters];
applyChapterFilter(); applyChapterFilter();
const totalEl = document.getElementById('total-chapters'); const totalEl = document.getElementById('total-chapters');
if (allChapters.length === 0) { if (allChapters.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>';
if (totalEl) totalEl.innerText = "0 Found"; if (totalEl) totalEl.innerText = "0 Found";
@@ -209,7 +256,6 @@ async function loadChapters(targetProvider = null) {
if (totalEl) totalEl.innerText = `${allChapters.length} Found`; if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
setupReadButton(); setupReadButton();
chapterPagination.setTotalItems(filteredChapters.length); chapterPagination.setTotalItems(filteredChapters.length);
chapterPagination.reset(); chapterPagination.reset();
renderChapterTable(); renderChapterTable();
@@ -236,16 +282,26 @@ function applyChapterFilter() {
function setupProviderFilter() { function setupProviderFilter() {
const select = document.getElementById('provider-filter'); const select = document.getElementById('provider-filter');
if (!select || availableExtensions.length === 0) return; if (!select) return;
select.style.display = 'inline-block'; select.style.display = 'inline-block';
select.innerHTML = ''; select.innerHTML = '';
// Opción para cargar todo
const allOpt = document.createElement('option'); const allOpt = document.createElement('option');
allOpt.value = 'all'; allOpt.value = 'all';
allOpt.innerText = 'Load All (Slower)'; allOpt.innerText = 'Load All (Slower)';
select.appendChild(allOpt); 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 => { availableExtensions.forEach(ext => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = ext; opt.value = ext;
@@ -253,7 +309,10 @@ function setupProviderFilter() {
select.appendChild(opt); 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; select.value = extensionName;
} else if (availableExtensions.length > 0) { } else if (availableExtensions.length > 0) {
select.value = availableExtensions[0]; select.value = availableExtensions[0];
@@ -315,7 +374,14 @@ function renderChapterTable() {
} }
function openReader(chapterId, provider) { 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() { function setupModalClickOutside() {

View File

@@ -129,11 +129,44 @@ async function loadChapter() {
if (!source) { if (!source) {
source = 'anilist'; 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 { try {
const res = await fetch(newEndpoint); const res = await fetch(newEndpoint);
const data = await res.json(); const data = await res.json();
if (provider === 'local') {
const unit = data.units[Number(chapter)];
if (!unit) {
reader.innerHTML = '<div class="loading-container"><span>Chapter not found</span></div>';
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) { if (data.title) {
chapterLabel.textContent = data.title; chapterLabel.textContent = data.title;
@@ -160,8 +193,13 @@ async function loadChapter() {
reader.innerHTML = ''; reader.innerHTML = '';
if (data.type === 'manga') { if (data.type === 'manga') {
if (provider === 'local' && data.format === 'cbz') {
currentPages = data.pages.map(url => ({ url }));
loadManga(currentPages);
} else {
currentPages = data.pages || []; currentPages = data.pages || [];
loadManga(currentPages); loadManga(currentPages);
}
} else if (data.type === 'ln') { } else if (data.type === 'ln') {
loadLN(data.content); loadLN(data.content);
} }
@@ -281,7 +319,9 @@ function createImageElement(page, index) {
img.className = 'page-img'; img.className = 'page-img';
img.dataset.index = index; 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"; const placeholder = "/public/assets/placeholder.svg";
img.onerror = () => { img.onerror = () => {

View File

@@ -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 = '<div class="skeleton-card"></div>'.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 = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 3rem;">No books found in your local library.</p>';
return;
}
renderLocalEntries(entries);
} catch (err) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-danger); padding: 3rem;">Error loading local books.</p>';
}
}
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 `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${chapters} Chapters
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).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}`;
}

View File

@@ -62,6 +62,7 @@
<div class="meta-row"> <div class="meta-row">
<div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div> <div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill" id="local-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill score" id="score">--% Score</div> <div class="pill score" id="score">--% Score</div>
<div class="pill" id="genres">Action</div> <div class="pill" id="genres">Action</div>
</div> </div>

View File

@@ -9,6 +9,7 @@
<link rel="stylesheet" href="/views/css/components/hero.css"> <link rel="stylesheet" href="/views/css/components/hero.css">
<link rel="stylesheet" href="/views/css/components/anilist-modal.css"> <link rel="stylesheet" href="/views/css/components/anilist-modal.css">
<link rel="stylesheet" href="/views/css/components/updateNotifier.css"> <link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="stylesheet" href="/views/css/components/local-library.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon"> <link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
</head> </head>
<body> <body>
@@ -36,9 +37,40 @@
</div> </div>
</div> </div>
</div> </div>
<button class="library-mode-btn icon-only" id="library-mode-btn" onclick="toggleLibraryMode()" title="Switch library mode">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</button>
</div> </div>
<main id="local-content" class="hidden">
<main> <section class="section">
<div class="section-header">
<div class="section-title">Local Books Library</div>
<button class="btn-secondary" onclick="scanLocalLibrary()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9"/><path d="M21 3v6h-6"/>
</svg>
<span id="scan-text">Scan Library</span>
</button>
</div>
<div class="local-filters">
<div class="filter-group">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
</div>
<div class="filter-group">
<button class="filter-btn" data-sort="az">AZ</button>
</div>
</div>
<div class="local-library-grid" id="local-entries-grid">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
</section>
</main>
<main id="online-content">
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
<div class="section-title">Continue Reading</div> <div class="section-title">Continue Reading</div>
@@ -87,7 +119,7 @@
<script src="/src/scripts/utils/list-modal-manager.js"></script> <script src="/src/scripts/utils/list-modal-manager.js"></script>
<script src="/src/scripts/utils/continue-watching-manager.js"></script> <script src="/src/scripts/utils/continue-watching-manager.js"></script>
<script src="/src/scripts/books/books.js"></script> <script src="/src/scripts/books/books.js"></script>
<script src="/src/scripts/local-library-books.js"></script>
<script src="/src/scripts/updateNotifier.js"></script> <script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/auth-guard.js"></script> <script src="/src/scripts/auth-guard.js"></script>
</body> </body>