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

@@ -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());
}

View File

@@ -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;

View File

@@ -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 = '<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 {
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 = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>';
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() {

View File

@@ -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 = '<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) {
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 = () => {

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="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" id="genres">Action</div>
</div>

View File

@@ -9,6 +9,7 @@
<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/updateNotifier.css">
<link rel="stylesheet" href="/views/css/components/local-library.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
</head>
<body>
@@ -36,9 +37,40 @@
</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>
<main>
<main id="local-content" class="hidden">
<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">
<div class="section-header">
<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/continue-watching-manager.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/auth-guard.js"></script>
</body>