support for novels and lot of formats for books

This commit is contained in:
2025-12-28 18:31:54 +01:00
parent 6222e7736f
commit 48e1939d2a
16 changed files with 1798 additions and 236 deletions

View File

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

View File

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

View File

@@ -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; // ← ID interna
} 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) {
@@ -484,4 +485,3 @@ setInterval(() => {
}, 60000);
loadMetadata();
loadExtensions();

View File

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

View File

@@ -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;
}
}
@@ -205,13 +215,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);
}

View File

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