support for novels and lot of formats for books
This commit is contained in:
@@ -1,21 +1,14 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { getConfig as loadConfig, setConfig as saveConfig } from '../../shared/config.js';
|
||||
import { getConfig as loadConfig } from '../../shared/config.js';
|
||||
import { queryOne, queryAll, run } from '../../shared/database.js';
|
||||
import crypto from 'crypto';
|
||||
import fs from "fs";
|
||||
import { PathLike } from "node:fs";
|
||||
import path from "path";
|
||||
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service";
|
||||
import {getBookById, searchBooksAniList, searchBooksLocal} from "../books/books.service";
|
||||
import {getBookById, searchBooksAniList} from "../books/books.service";
|
||||
import AdmZip from 'adm-zip';
|
||||
|
||||
type SetConfigBody = {
|
||||
library?: {
|
||||
anime?: string | null;
|
||||
manga?: string | null;
|
||||
novels?: string | null;
|
||||
};
|
||||
};
|
||||
import EPub from 'epub';
|
||||
|
||||
type ScanQuery = {
|
||||
mode?: 'full' | 'incremental';
|
||||
@@ -37,10 +30,21 @@ async function resolveEntryMetadata(entry: any, type: string) {
|
||||
? await searchAnimeLocal(query)
|
||||
: await searchBooksAniList(query);
|
||||
|
||||
const first = results?.[0];
|
||||
let picked = null;
|
||||
|
||||
if (first?.id) {
|
||||
matchedId = first.id;
|
||||
if (type !== 'anime' && Array.isArray(results)) {
|
||||
console.log(type)
|
||||
if (entry.type === 'novels') {
|
||||
picked = results.find(r => r.format === 'NOVEL');
|
||||
} else if (entry.type === 'manga') {
|
||||
picked = results.find(r => r.format !== 'NOVEL');
|
||||
}
|
||||
}
|
||||
|
||||
picked ??= results?.[0];
|
||||
|
||||
if (picked?.id) {
|
||||
matchedId = picked.id;
|
||||
|
||||
await run(
|
||||
`UPDATE local_entries
|
||||
@@ -104,9 +108,13 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(fullPath, { withFileTypes: true })
|
||||
.filter(f => f.isFile())
|
||||
.filter(f =>
|
||||
f.isFile() ||
|
||||
(type === 'manga' && f.isDirectory())
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
|
||||
let unit = 1;
|
||||
|
||||
for (const file of files) {
|
||||
@@ -191,27 +199,30 @@ export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
|
||||
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = Number(parts[0]);
|
||||
let end = parts[1] ? Number(parts[1]) : stat.size - 1;
|
||||
const end = parts[1] ? Number(parts[1]) : stat.size - 1;
|
||||
|
||||
// Validate range values
|
||||
if (
|
||||
Number.isNaN(start) ||
|
||||
Number.isNaN(end) ||
|
||||
start < 0 ||
|
||||
start >= stat.size ||
|
||||
end < start ||
|
||||
end >= stat.size
|
||||
) {
|
||||
end = stat.size - 1;
|
||||
return reply.status(416).send({ error: 'INVALID_RANGE' });
|
||||
}
|
||||
|
||||
const contentLength = end - start + 1;
|
||||
|
||||
reply
|
||||
.status(206)
|
||||
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
|
||||
.header('Accept-Ranges', 'bytes')
|
||||
.header('Content-Length', end - start + 1)
|
||||
.header('Content-Length', contentLength)
|
||||
.header('Content-Type', 'video/mp4');
|
||||
|
||||
return fs.createReadStream(file.file_path, { start, end });
|
||||
|
||||
}
|
||||
|
||||
type MatchBody = {
|
||||
@@ -247,17 +258,13 @@ export async function matchEntry(
|
||||
return { status: 'OK', matched: !!matched_id };
|
||||
}
|
||||
|
||||
export async function getUnits(
|
||||
request: FastifyRequest<{ Params: Params }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
export async function getUnits(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { type, id } = request.params as { type: string, id: string };
|
||||
const { id } = request.params as { 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],
|
||||
`SELECT id, type, matched_id FROM local_entries WHERE matched_id = ?`,
|
||||
[Number(id)],
|
||||
'local_library'
|
||||
);
|
||||
|
||||
@@ -274,24 +281,82 @@ export async function getUnits(
|
||||
'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();
|
||||
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
|
||||
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
|
||||
|
||||
// Detectar si es un archivo comprimido (capítulo único) o carpeta
|
||||
const isDirectory = fs.existsSync(file.file_path) &&
|
||||
fs.statSync(file.file_path).isDirectory();
|
||||
const NOVEL_EXTS = ['.epub', '.pdf', '.txt', '.md', '.docx', '.mobi'];
|
||||
|
||||
function isImageFolder(folderPath: string): boolean {
|
||||
if (!fs.existsSync(folderPath)) return false;
|
||||
if (!fs.statSync(folderPath).isDirectory()) return false;
|
||||
|
||||
const files = fs.readdirSync(folderPath);
|
||||
return files.some(f => MANGA_IMAGE_EXTS.includes(path.extname(f).toLowerCase()));
|
||||
}
|
||||
|
||||
const units = files
|
||||
.map((file: any) => {
|
||||
const fileExt = path.extname(file.file_path).toLowerCase();
|
||||
const isDir = fs.existsSync(file.file_path) &&
|
||||
fs.statSync(file.file_path).isDirectory();
|
||||
|
||||
// ===== MANGA =====
|
||||
if (entry.type === 'manga') {
|
||||
if (MANGA_ARCHIVES.includes(fileExt)) {
|
||||
return {
|
||||
id: file.id,
|
||||
number: file.unit_number,
|
||||
name: path.basename(file.file_path),
|
||||
type: 'chapter',
|
||||
format: fileExt.replace('.', ''),
|
||||
path: file.file_path
|
||||
};
|
||||
}
|
||||
|
||||
if (isDir && isImageFolder(file.file_path)) {
|
||||
return {
|
||||
id: file.id,
|
||||
number: file.unit_number,
|
||||
name: path.basename(file.file_path),
|
||||
type: 'chapter',
|
||||
format: 'folder',
|
||||
path: file.file_path
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.type === 'novels') {
|
||||
if (NOVEL_EXTS.includes(fileExt)) {
|
||||
return {
|
||||
id: file.id,
|
||||
number: file.unit_number,
|
||||
name: path.basename(file.file_path),
|
||||
type: 'chapter',
|
||||
format: fileExt.replace('.', ''),
|
||||
path: file.file_path
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.type === 'anime') {
|
||||
return {
|
||||
id: file.id,
|
||||
number: file.unit_number,
|
||||
name: path.basename(file.file_path),
|
||||
type: 'episode',
|
||||
format: fileExt.replace('.', ''),
|
||||
path: file.file_path
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
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,
|
||||
@@ -306,7 +371,7 @@ export async function getUnits(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCbzPages(request: FastifyRequest, reply: FastifyReply) {
|
||||
export async function getManifest(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { unitId } = request.params as any;
|
||||
|
||||
const file = await queryOne(
|
||||
@@ -319,20 +384,73 @@ export async function getCbzPages(request: FastifyRequest, reply: FastifyReply)
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const zip = new AdmZip(file.file_path);
|
||||
const ext = path.extname(file.file_path).toLowerCase();
|
||||
|
||||
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}`
|
||||
);
|
||||
// ===== MANGA =====
|
||||
if (['.cbz', '.cbr', '.zip'].includes(ext)) {
|
||||
const zip = new AdmZip(file.file_path);
|
||||
|
||||
return { pages };
|
||||
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) => ({
|
||||
id: i,
|
||||
url: `/api/library/${unitId}/resource/${i}`
|
||||
}));
|
||||
|
||||
return {
|
||||
type: 'manga',
|
||||
format: 'archive',
|
||||
pages
|
||||
};
|
||||
}
|
||||
|
||||
if (fs.statSync(file.file_path).isDirectory()) {
|
||||
const pages = fs.readdirSync(file.file_path)
|
||||
.filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
||||
.map((_, i) => ({
|
||||
id: i,
|
||||
url: `/api/library/${unitId}/resource/${i}`
|
||||
}));
|
||||
|
||||
return {
|
||||
type: 'manga',
|
||||
format: 'folder',
|
||||
pages
|
||||
};
|
||||
}
|
||||
|
||||
// ===== NOVEL =====
|
||||
if (ext === '.epub') {
|
||||
return {
|
||||
type: 'ln',
|
||||
format: 'epub',
|
||||
url: `/api/library/${unitId}/resource/epub`
|
||||
};
|
||||
}
|
||||
|
||||
if (['.txt', '.md'].includes(ext)) {
|
||||
return {
|
||||
type: 'ln',
|
||||
format: 'text',
|
||||
url: `/api/library/${unitId}/resource/text`
|
||||
};
|
||||
}
|
||||
|
||||
if (ext === '.pdf') {
|
||||
return {
|
||||
type: 'ln',
|
||||
format: 'pdf',
|
||||
url: `/api/library/${unitId}/resource/pdf`
|
||||
};
|
||||
}
|
||||
|
||||
return reply.status(400).send({ error: 'UNSUPPORTED_FORMAT' });
|
||||
}
|
||||
|
||||
export async function getCbzPage(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { unitId, page } = request.params as any;
|
||||
export async function getPage(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { unitId, resId } = request.params as any;
|
||||
|
||||
const file = await queryOne(
|
||||
`SELECT file_path FROM local_files WHERE id = ?`,
|
||||
@@ -342,16 +460,83 @@ export async function getCbzPage(request: FastifyRequest, reply: FastifyReply) {
|
||||
|
||||
if (!file) return reply.status(404).send();
|
||||
|
||||
const zip = new AdmZip(file.file_path);
|
||||
const ext = path.extname(file.file_path).toLowerCase();
|
||||
|
||||
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 }));
|
||||
// ===== CBZ PAGE =====
|
||||
if (['.cbz', '.zip', '.cbr'].includes(ext)) {
|
||||
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();
|
||||
const entry = images[Number(resId)];
|
||||
if (!entry) return reply.status(404).send();
|
||||
|
||||
reply
|
||||
.header('Content-Type', 'image/jpeg')
|
||||
.send(entry.getData());
|
||||
return reply
|
||||
.header('Content-Type', 'image/jpeg')
|
||||
.send(entry.getData());
|
||||
}
|
||||
|
||||
if (fs.statSync(file.file_path).isDirectory()) {
|
||||
const images = fs.readdirSync(file.file_path)
|
||||
.filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
|
||||
const img = images[Number(resId)];
|
||||
if (!img) return reply.status(404).send();
|
||||
|
||||
const imgPath = path.join(file.file_path, img);
|
||||
const stat = fs.statSync(imgPath);
|
||||
|
||||
reply
|
||||
.header('Content-Length', stat.size)
|
||||
.header('Content-Type', 'image/jpeg');
|
||||
|
||||
return fs.createReadStream(imgPath);
|
||||
}
|
||||
|
||||
if (ext === '.epub') {
|
||||
const html = await parseEpubToHtml(file.file_path);
|
||||
|
||||
return reply
|
||||
.header('Content-Type', 'text/html; charset=utf-8')
|
||||
.send(html);
|
||||
}
|
||||
|
||||
// ===== TXT / MD =====
|
||||
if (['.txt', '.md'].includes(ext)) {
|
||||
const text = fs.readFileSync(file.file_path, 'utf8');
|
||||
|
||||
return reply
|
||||
.header('Content-Type', 'text/html; charset=utf-8')
|
||||
.send(`<div class="ln-content"><pre>${text}</pre></div>`);
|
||||
}
|
||||
|
||||
return reply.status(400).send();
|
||||
}
|
||||
|
||||
function parseEpubToHtml(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const epub = new EPub(filePath);
|
||||
|
||||
epub.on('end', async () => {
|
||||
let html = '';
|
||||
|
||||
for (const id of epub.flow.map(f => f.id)) {
|
||||
const chapter = await new Promise<string>((res, rej) => {
|
||||
epub.getChapter(id, (err, text) => {
|
||||
if (err) rej(err);
|
||||
else res(text);
|
||||
});
|
||||
});
|
||||
|
||||
html += `<section class="ln-chapter">${chapter}</section>`;
|
||||
}
|
||||
|
||||
resolve(html);
|
||||
});
|
||||
|
||||
epub.on('error', reject);
|
||||
epub.parse();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,9 +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);
|
||||
fastify.get('/library/:id/units', controller.getUnits);
|
||||
fastify.get('/library/:unitId/manifest', controller.getManifest);
|
||||
fastify.get('/library/:unitId/resource/:resId', controller.getPage);
|
||||
}
|
||||
|
||||
export default localRoutes;
|
||||
@@ -32,12 +32,12 @@ let localEntryId = null;
|
||||
async function checkLocal() {
|
||||
try {
|
||||
const res = await fetch(`/api/library/anime/${animeId}`);
|
||||
if (!res.ok) return;
|
||||
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
localEntryId = data.id;
|
||||
|
||||
} catch {}
|
||||
return data.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAniSkip(malId, episode, duration) {
|
||||
@@ -53,7 +53,7 @@ async function loadAniSkip(malId, episode, duration) {
|
||||
}
|
||||
|
||||
async function loadMetadata() {
|
||||
checkLocal();
|
||||
localEntryId = await checkLocal();
|
||||
try {
|
||||
const sourceQuery = (extName === 'local' || !extName) ? "source=anilist" : `source=${extName}`;
|
||||
const res = await fetch(`/api/anime/${animeId}?${sourceQuery}`);
|
||||
@@ -133,6 +133,7 @@ async function loadMetadata() {
|
||||
} catch (error) {
|
||||
console.error('Error loading metadata:', error);
|
||||
}
|
||||
await loadExtensions();
|
||||
}
|
||||
|
||||
async function applyAniSkip(video) {
|
||||
@@ -439,9 +440,9 @@ document.getElementById('next-btn').onclick = () => {
|
||||
|
||||
if (currentEpisode <= 1) document.getElementById('prev-btn').disabled = true;
|
||||
|
||||
// Actualizar progreso cada 1 minuto si el video está reproduciéndose
|
||||
setInterval(() => {
|
||||
if (plyrInstance && !plyrInstance.paused) sendProgress();
|
||||
}, 60000);
|
||||
|
||||
loadMetadata();
|
||||
loadExtensions();
|
||||
@@ -18,7 +18,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
async function checkLocalLibraryEntry() {
|
||||
try {
|
||||
const res = await fetch(`/api/library/manga/${bookId}`);
|
||||
const libraryType =
|
||||
bookData?.entry_type === 'NOVEL' ? 'novels' : 'manga';
|
||||
|
||||
const res = await fetch(`/api/library/${libraryType}/${bookId}`);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
@@ -38,18 +41,6 @@ async function checkLocalLibraryEntry() {
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -61,8 +52,8 @@ async function init() {
|
||||
extensionName = urlData.extensionName;
|
||||
bookId = urlData.entityId;
|
||||
bookSlug = urlData.slug;
|
||||
await checkLocalLibraryEntry();
|
||||
await loadBookMetadata();
|
||||
await checkLocalLibraryEntry();
|
||||
|
||||
await loadAvailableExtensions();
|
||||
await loadChapters();
|
||||
@@ -220,7 +211,7 @@ async function loadChapters(targetProvider = null) {
|
||||
|
||||
if (isLocalRequest) {
|
||||
// Nuevo endpoint para archivos locales
|
||||
fetchUrl = `/api/library/manga/${bookId}/units`;
|
||||
fetchUrl = `/api/library/${bookId}/units`;
|
||||
} else {
|
||||
const source = extensionName || 'anilist';
|
||||
fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
|
||||
|
||||
@@ -132,7 +132,7 @@ async function loadChapter() {
|
||||
let newEndpoint;
|
||||
|
||||
if (provider === 'local') {
|
||||
newEndpoint = `/api/library/manga/${bookId}/units`;
|
||||
newEndpoint = `/api/library/${bookId}/units`;
|
||||
} else {
|
||||
newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
|
||||
}
|
||||
@@ -142,29 +142,39 @@ async function loadChapter() {
|
||||
const data = await res.json();
|
||||
if (provider === 'local') {
|
||||
const unit = data.units[Number(chapter)];
|
||||
if (!unit) return;
|
||||
|
||||
if (!unit) {
|
||||
reader.innerHTML = '<div class="loading-container"><span>Chapter not found</span></div>';
|
||||
return;
|
||||
}
|
||||
chapterLabel.textContent = unit.name;
|
||||
document.title = unit.name;
|
||||
|
||||
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();
|
||||
const manifestRes = await fetch(`/api/library/${unit.id}/manifest`);
|
||||
const manifest = await manifestRes.json();
|
||||
|
||||
reader.innerHTML = '';
|
||||
|
||||
// ===== MANGA =====
|
||||
if (manifest.type === 'manga') {
|
||||
currentType = 'manga';
|
||||
updateSettingsVisibility();
|
||||
applyStyles();
|
||||
|
||||
currentPages = pagesData.pages.map(url => ({ url }));
|
||||
reader.innerHTML = '';
|
||||
currentPages = manifest.pages;
|
||||
loadManga(currentPages);
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== LN =====
|
||||
if (manifest.type === 'ln') {
|
||||
currentType = 'ln';
|
||||
updateSettingsVisibility();
|
||||
applyStyles();
|
||||
|
||||
const contentRes = await fetch(manifest.url);
|
||||
const html = await contentRes.text();
|
||||
|
||||
loadLN(html);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,13 +203,8 @@ async function loadChapter() {
|
||||
reader.innerHTML = '';
|
||||
|
||||
if (data.type === 'manga') {
|
||||
if (provider === 'local' && data.format === 'cbz') {
|
||||
currentPages = data.pages.map(url => ({ url }));
|
||||
loadManga(currentPages);
|
||||
} else {
|
||||
currentPages = data.pages || [];
|
||||
loadManga(currentPages);
|
||||
}
|
||||
currentPages = data.pages || [];
|
||||
loadManga(currentPages);
|
||||
} else if (data.type === 'ln') {
|
||||
loadLN(data.content);
|
||||
}
|
||||
|
||||
@@ -26,21 +26,37 @@ async function loadLocalEntries() {
|
||||
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;
|
||||
const [mangaRes, novelRes] = await Promise.all([
|
||||
fetch('/api/library/manga'),
|
||||
fetch('/api/library/novels')
|
||||
]);
|
||||
|
||||
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>';
|
||||
const [manga, novel] = await Promise.all([
|
||||
mangaRes.json(),
|
||||
novelRes.json()
|
||||
]);
|
||||
|
||||
localEntries = [
|
||||
...manga.map(e => ({ ...e, type: 'manga' })),
|
||||
...novel.map(e => ({ ...e, type: 'novel' }))
|
||||
];
|
||||
|
||||
if (localEntries.length === 0) {
|
||||
grid.innerHTML = '<p style="grid-column:1/-1;text-align:center;padding:3rem;">No books found.</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>';
|
||||
|
||||
renderLocalEntries(localEntries);
|
||||
} catch {
|
||||
grid.innerHTML = '<p style="grid-column:1/-1;text-align:center;color:var(--color-danger);padding:3rem;">Error loading library.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function filterLocal(type) {
|
||||
if (type === 'all') renderLocalEntries(localEntries);
|
||||
else renderLocalEntries(localEntries.filter(e => e.type === type));
|
||||
}
|
||||
|
||||
function renderLocalEntries(entries) {
|
||||
const grid = document.getElementById('local-entries-grid');
|
||||
grid.innerHTML = entries.map(entry => {
|
||||
@@ -58,6 +74,7 @@ function renderLocalEntries(entries) {
|
||||
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
|
||||
${chapters} Chapters
|
||||
</p>
|
||||
<div class="badge">${entry.type}</div>
|
||||
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
|
||||
${entry.matched ? '● Linked' : '○ Unlinked'}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user