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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user