organised local library backend

This commit is contained in:
2025-12-28 18:55:16 +01:00
parent 48e1939d2a
commit c28948f6e9
4 changed files with 1018 additions and 824 deletions

View File

@@ -1,14 +1,6 @@
import { FastifyRequest, FastifyReply } from 'fastify'; import {FastifyReply, FastifyRequest} from 'fastify';
import { getConfig as loadConfig } from '../../shared/config.js'; import fs from 'fs';
import { queryOne, queryAll, run } from '../../shared/database.js'; import * as service from './local.service';
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} from "../books/books.service";
import AdmZip from 'adm-zip';
import EPub from 'epub';
type ScanQuery = { type ScanQuery = {
mode?: 'full' | 'incremental'; mode?: 'full' | 'incremental';
@@ -19,117 +11,19 @@ type Params = {
id?: string; id?: string;
}; };
async function resolveEntryMetadata(entry: any, type: string) { type MatchBody = {
let metadata = null; source: 'anilist';
let matchedId = entry.matched_id; matched_id: number | null;
};
if (!matchedId) {
const query = entry.folder_name;
const results = type === 'anime'
? await searchAnimeLocal(query)
: await searchBooksAniList(query);
let picked = null;
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
SET matched_id = ?, matched_source = 'anilist'
WHERE id = ?`,
[matchedId, entry.id],
'local_library'
);
}
}
if (matchedId) {
metadata = type === 'anime'
? await getAnimeById(matchedId)
: await getBookById(matchedId);
}
return {
id: entry.id,
type: entry.type,
matched: !!matchedId,
metadata
};
}
export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) { export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) {
try { try {
const mode = request.query.mode || 'incremental'; const mode = request.query.mode || 'incremental';
const config = loadConfig(); return await service.performLibraryScan(mode);
} catch (err: any) {
if (!config.library) { if (err.message === 'NO_LIBRARY_CONFIGURED') {
return reply.status(400).send({ error: 'NO_LIBRARY_CONFIGURED' }); return reply.status(400).send({ error: 'NO_LIBRARY_CONFIGURED' });
} }
if (mode === 'full') {
await run(`DELETE FROM local_files`, [], 'local_library');
await run(`DELETE FROM local_entries`, [], 'local_library');
}
for (const [type, basePath] of Object.entries(config.library)) {
if (!basePath || !fs.existsSync(<PathLike>basePath)) continue;
const dirs = fs.readdirSync(<string>basePath, { withFileTypes: true }).filter(d => d.isDirectory());
for (const dir of dirs) {
const fullPath = path.join(<string>basePath, dir.name);
const id = crypto.createHash('sha1').update(fullPath).digest('hex');
const now = Date.now();
const existing = await queryOne(`SELECT id FROM local_entries WHERE id = ?`, [id], 'local_library');
if (existing) {
await run(`UPDATE local_entries SET last_scan = ? WHERE id = ?`, [now, id], 'local_library');
await run(`DELETE FROM local_files WHERE entry_id = ?`, [id], 'local_library');
} else {
await run(
`INSERT INTO local_entries (id, type, path, folder_name, last_scan) VALUES (?, ?, ?, ?, ?)`,
[id, type, fullPath, dir.name, now],
'local_library'
);
}
const files = fs.readdirSync(fullPath, { withFileTypes: true })
.filter(f =>
f.isFile() ||
(type === 'manga' && f.isDirectory())
)
.sort((a, b) => a.name.localeCompare(b.name));
let unit = 1;
for (const file of files) {
await run(
`INSERT INTO local_files (id, entry_id, file_path, unit_number)
VALUES (?, ?, ?, ?)`,
[crypto.randomUUID(), id, path.join(fullPath, file.name), unit],
'local_library'
);
unit++;
}
}
}
return { status: 'OK' };
} catch (err) {
return reply.status(500).send({ error: 'FAILED_TO_SCAN_LIBRARY' }); return reply.status(500).send({ error: 'FAILED_TO_SCAN_LIBRARY' });
} }
} }
@@ -137,9 +31,8 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue
export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try { try {
const { type } = request.params; const { type } = request.params;
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library'); const entries = await service.getEntriesByType(type);
return entries;
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
} catch { } catch {
return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' }); return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' });
} }
@@ -148,27 +41,13 @@ export async function listEntries(request: FastifyRequest<{ Params: Params }>, r
export async function getEntry(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { export async function getEntry(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try { try {
const { type, id } = request.params as { type: string, id: string }; const { type, id } = request.params as { type: string, id: string };
const entry = await service.getEntryDetails(type, id);
const entry = await queryOne(
`SELECT * FROM local_entries WHERE matched_id = ? AND type = ?`,
[Number(id), type],
'local_library'
);
if (!entry) { if (!entry) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
} }
const [details, files] = await Promise.all([ return entry;
resolveEntryMetadata(entry, type),
queryAll(
`SELECT id, file_path, unit_number FROM local_files WHERE entry_id = ? ORDER BY unit_number ASC`,
[id],
'local_library'
)
]);
return { ...details, files };
} catch { } catch {
return reply.status(500).send({ error: 'FAILED_TO_GET_ENTRY' }); return reply.status(500).send({ error: 'FAILED_TO_GET_ENTRY' });
} }
@@ -177,31 +56,26 @@ export async function getEntry(request: FastifyRequest<{ Params: Params }>, repl
export async function streamUnit(request: FastifyRequest, reply: FastifyReply) { export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
const { id, unit } = request.params as any; const { id, unit } = request.params as any;
const file = await queryOne( const fileInfo = await service.getFileForStreaming(id, unit);
`SELECT file_path FROM local_files WHERE entry_id = ? AND unit_number = ?`,
[id, unit],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) { if (!fileInfo) {
return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
} }
const stat = fs.statSync(file.file_path); const { filePath, stat } = fileInfo;
const range = request.headers.range; const range = request.headers.range;
if (!range) { if (!range) {
reply reply
.header('Content-Length', stat.size) .header('Content-Length', stat.size)
.header('Content-Type', 'video/mp4'); .header('Content-Type', 'video/mp4');
return fs.createReadStream(file.file_path); return fs.createReadStream(filePath);
} }
const parts = range.replace(/bytes=/, '').split('-'); const parts = range.replace(/bytes=/, '').split('-');
const start = Number(parts[0]); const start = Number(parts[0]);
const end = parts[1] ? Number(parts[1]) : stat.size - 1; const end = parts[1] ? Number(parts[1]) : stat.size - 1;
// Validate range values
if ( if (
Number.isNaN(start) || Number.isNaN(start) ||
Number.isNaN(end) || Number.isNaN(end) ||
@@ -222,14 +96,9 @@ export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
.header('Content-Length', contentLength) .header('Content-Length', contentLength)
.header('Content-Type', 'video/mp4'); .header('Content-Type', 'video/mp4');
return fs.createReadStream(file.file_path, { start, end }); return fs.createReadStream(filePath, { start, end });
} }
type MatchBody = {
source: 'anilist';
matched_id: number | null;
};
export async function matchEntry( export async function matchEntry(
request: FastifyRequest<{ Body: MatchBody }>, request: FastifyRequest<{ Body: MatchBody }>,
reply: FastifyReply reply: FastifyReply
@@ -237,134 +106,25 @@ export async function matchEntry(
const { id, type } = request.params as any; const { id, type } = request.params as any;
const { source, matched_id } = request.body; const { source, matched_id } = request.body;
const entry = await queryOne( const result = await service.updateEntryMatch(id, type, source, matched_id);
`SELECT id FROM local_entries WHERE id = ? AND type = ?`,
[id, type],
'local_library'
);
if (!entry) { if (!result) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
} }
await run( return result;
`UPDATE local_entries
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) { export async function getUnits(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try { try {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const units = await service.getEntryUnits(id);
const entry = await queryOne( if (!units) {
`SELECT id, type, matched_id FROM local_entries WHERE matched_id = ?`,
[Number(id)],
'local_library'
);
if (!entry) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
} }
// Obtener todos los archivos/unidades ordenados return units;
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'
);
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
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 {
entry_id: entry.id,
matched_id: entry.matched_id,
type: entry.type,
total: units.length,
units
};
} catch (err) { } catch (err) {
console.error('Error getting units:', err); console.error('Error getting units:', err);
return reply.status(500).send({ error: 'FAILED_TO_GET_UNITS' }); return reply.status(500).send({ error: 'FAILED_TO_GET_UNITS' });
@@ -374,169 +134,52 @@ export async function getUnits(request: FastifyRequest<{ Params: Params }>, repl
export async function getManifest(request: FastifyRequest, reply: FastifyReply) { export async function getManifest(request: FastifyRequest, reply: FastifyReply) {
const { unitId } = request.params as any; const { unitId } = request.params as any;
const file = await queryOne( try {
`SELECT file_path FROM local_files WHERE id = ?`, const manifest = await service.getUnitManifest(unitId);
[unitId],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) { if (!manifest) {
return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
}
return manifest;
} catch (err: any) {
if (err.message === 'UNSUPPORTED_FORMAT') {
return reply.status(400).send({ error: 'UNSUPPORTED_FORMAT' });
}
return reply.status(500).send({ error: 'FAILED_TO_GET_MANIFEST' });
} }
const ext = path.extname(file.file_path).toLowerCase();
// ===== MANGA =====
if (['.cbz', '.cbr', '.zip'].includes(ext)) {
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) => ({
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 getPage(request: FastifyRequest, reply: FastifyReply) { export async function getPage(request: FastifyRequest, reply: FastifyReply) {
const { unitId, resId } = request.params as any; const { unitId, resId } = request.params as any;
const file = await queryOne( const resource = await service.getUnitResource(unitId, resId);
`SELECT file_path FROM local_files WHERE id = ?`,
[unitId],
'local_library'
);
if (!file) return reply.status(404).send(); if (!resource) {
return reply.status(404).send();
const ext = path.extname(file.file_path).toLowerCase();
// ===== 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[Number(resId)];
if (!entry) return reply.status(404).send();
return reply
.header('Content-Type', 'image/jpeg')
.send(entry.getData());
} }
if (fs.statSync(file.file_path).isDirectory()) { if (resource.type === 'image') {
const images = fs.readdirSync(file.file_path) if (resource.data) {
.filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f)) return reply
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); .header('Content-Type', 'image/jpeg')
.send(resource.data);
}
const img = images[Number(resId)]; if (resource.path && resource.size) {
if (!img) return reply.status(404).send(); reply
.header('Content-Length', resource.size)
.header('Content-Type', 'image/jpeg');
const imgPath = path.join(file.file_path, img); return fs.createReadStream(resource.path);
const stat = fs.statSync(imgPath); }
reply
.header('Content-Length', stat.size)
.header('Content-Type', 'image/jpeg');
return fs.createReadStream(imgPath);
} }
if (ext === '.epub') { if (resource.type === 'html') {
const html = await parseEpubToHtml(file.file_path);
return reply return reply
.header('Content-Type', 'text/html; charset=utf-8') .header('Content-Type', 'text/html; charset=utf-8')
.send(html); .send(resource.data);
}
// ===== 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(); 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

@@ -0,0 +1,454 @@
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 } from "../books/books.service";
import AdmZip from 'adm-zip';
import EPub from 'epub';
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
const NOVEL_EXTS = ['.epub', '.pdf', '.txt', '.md', '.docx', '.mobi'];
export async function resolveEntryMetadata(entry: any, type: string) {
let metadata = null;
let matchedId = entry.matched_id;
if (!matchedId) {
const query = entry.folder_name;
const results = type === 'anime'
? await searchAnimeLocal(query)
: await searchBooksAniList(query);
let picked = null;
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
SET matched_id = ?, matched_source = 'anilist'
WHERE id = ?`,
[matchedId, entry.id],
'local_library'
);
}
}
if (matchedId) {
metadata = type === 'anime'
? await getAnimeById(matchedId)
: await getBookById(matchedId);
}
return {
id: entry.id,
type: entry.type,
matched: !!matchedId,
metadata
};
}
export async function performLibraryScan(mode: 'full' | 'incremental' = 'incremental') {
const config = loadConfig();
if (!config.library) {
throw new Error('NO_LIBRARY_CONFIGURED');
}
if (mode === 'full') {
await run(`DELETE FROM local_files`, [], 'local_library');
await run(`DELETE FROM local_entries`, [], 'local_library');
}
for (const [type, basePath] of Object.entries(config.library)) {
if (!basePath || !fs.existsSync(<PathLike>basePath)) continue;
const dirs = fs.readdirSync(<string>basePath, { withFileTypes: true }).filter(d => d.isDirectory());
for (const dir of dirs) {
const fullPath = path.join(<string>basePath, dir.name);
const id = crypto.createHash('sha1').update(fullPath).digest('hex');
const now = Date.now();
const existing = await queryOne(`SELECT id FROM local_entries WHERE id = ?`, [id], 'local_library');
if (existing) {
await run(`UPDATE local_entries SET last_scan = ? WHERE id = ?`, [now, id], 'local_library');
await run(`DELETE FROM local_files WHERE entry_id = ?`, [id], 'local_library');
} else {
await run(
`INSERT INTO local_entries (id, type, path, folder_name, last_scan) VALUES (?, ?, ?, ?, ?)`,
[id, type, fullPath, dir.name, now],
'local_library'
);
}
const files = fs.readdirSync(fullPath, { withFileTypes: true })
.filter(f =>
f.isFile() ||
(type === 'manga' && f.isDirectory())
)
.sort((a, b) => a.name.localeCompare(b.name));
let unit = 1;
for (const file of files) {
await run(
`INSERT INTO local_files (id, entry_id, file_path, unit_number)
VALUES (?, ?, ?, ?)`,
[crypto.randomUUID(), id, path.join(fullPath, file.name), unit],
'local_library'
);
unit++;
}
}
}
return { status: 'OK' };
}
export async function getEntriesByType(type: string) {
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library');
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
}
export async function getEntryDetails(type: string, id: string) {
const entry = await queryOne(
`SELECT * FROM local_entries WHERE matched_id = ? AND type = ?`,
[Number(id), type],
'local_library'
);
if (!entry) {
return null;
}
const [details, files] = await Promise.all([
resolveEntryMetadata(entry, type),
queryAll(
`SELECT id, file_path, unit_number FROM local_files WHERE entry_id = ? ORDER BY unit_number ASC`,
[id],
'local_library'
)
]);
return { ...details, files };
}
export async function getFileForStreaming(id: string, unit: string) {
const file = await queryOne(
`SELECT file_path FROM local_files WHERE entry_id = ? AND unit_number = ?`,
[id, unit],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) {
return null;
}
return {
filePath: file.file_path,
stat: fs.statSync(file.file_path)
};
}
export async function updateEntryMatch(id: string, type: string, source: string, matchedId: number | null) {
const entry = await queryOne(
`SELECT id FROM local_entries WHERE id = ? AND type = ?`,
[id, type],
'local_library'
);
if (!entry) {
return null;
}
await run(
`UPDATE local_entries
SET matched_source = ?, matched_id = ?
WHERE id = ?`,
[source, matchedId, id],
'local_library'
);
return { status: 'OK', matched: !!matchedId };
}
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()));
}
export async function getEntryUnits(id: string) {
const entry = await queryOne(
`SELECT id, type, matched_id FROM local_entries WHERE matched_id = ?`,
[Number(id)],
'local_library'
);
if (!entry) {
return null;
}
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'
);
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();
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 {
entry_id: entry.id,
matched_id: entry.matched_id,
type: entry.type,
total: units.length,
units
};
}
export async function getUnitManifest(unitId: string) {
const file = await queryOne(
`SELECT file_path FROM local_files WHERE id = ?`,
[unitId],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) {
return null;
}
const ext = path.extname(file.file_path).toLowerCase();
if (['.cbz', '.cbr', '.zip'].includes(ext)) {
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) => ({
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
};
}
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`
};
}
throw new Error('UNSUPPORTED_FORMAT');
}
export async function getUnitResource(unitId: string, resId: string) {
const file = await queryOne(
`SELECT file_path FROM local_files WHERE id = ?`,
[unitId],
'local_library'
);
if (!file) return null;
const ext = path.extname(file.file_path).toLowerCase();
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[Number(resId)];
if (!entry) return null;
return {
type: 'image',
data: 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 null;
const imgPath = path.join(file.file_path, img);
const stat = fs.statSync(imgPath);
return {
type: 'image',
path: imgPath,
size: stat.size
};
}
if (ext === '.epub') {
const html = await parseEpubToHtml(file.file_path);
return {
type: 'html',
data: html
};
}
if (['.txt', '.md'].includes(ext)) {
const text = fs.readFileSync(file.file_path, 'utf8');
return {
type: 'html',
data: `<div class="ln-content"><pre>${text}</pre></div>`
};
}
return null;
}
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

@@ -1,14 +1,6 @@
import { FastifyRequest, FastifyReply } from 'fastify'; import {FastifyReply, FastifyRequest} from 'fastify';
import { getConfig as loadConfig } from '../../shared/config.js'; import fs from 'fs';
import { queryOne, queryAll, run } from '../../shared/database.js'; import * as service from './local.service';
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} from "../books/books.service";
import AdmZip from 'adm-zip';
import EPub from 'epub';
type ScanQuery = { type ScanQuery = {
mode?: 'full' | 'incremental'; mode?: 'full' | 'incremental';
@@ -19,117 +11,19 @@ type Params = {
id?: string; id?: string;
}; };
async function resolveEntryMetadata(entry: any, type: string) { type MatchBody = {
let metadata = null; source: 'anilist';
let matchedId = entry.matched_id; matched_id: number | null;
};
if (!matchedId) {
const query = entry.folder_name;
const results = type === 'anime'
? await searchAnimeLocal(query)
: await searchBooksAniList(query);
let picked = null;
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
SET matched_id = ?, matched_source = 'anilist'
WHERE id = ?`,
[matchedId, entry.id],
'local_library'
);
}
}
if (matchedId) {
metadata = type === 'anime'
? await getAnimeById(matchedId)
: await getBookById(matchedId);
}
return {
id: entry.id,
type: entry.type,
matched: !!matchedId,
metadata
};
}
export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) { export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) {
try { try {
const mode = request.query.mode || 'incremental'; const mode = request.query.mode || 'incremental';
const config = loadConfig(); return await service.performLibraryScan(mode);
} catch (err: any) {
if (!config.library) { if (err.message === 'NO_LIBRARY_CONFIGURED') {
return reply.status(400).send({ error: 'NO_LIBRARY_CONFIGURED' }); return reply.status(400).send({ error: 'NO_LIBRARY_CONFIGURED' });
} }
if (mode === 'full') {
await run(`DELETE FROM local_files`, [], 'local_library');
await run(`DELETE FROM local_entries`, [], 'local_library');
}
for (const [type, basePath] of Object.entries(config.library)) {
if (!basePath || !fs.existsSync(<PathLike>basePath)) continue;
const dirs = fs.readdirSync(<string>basePath, { withFileTypes: true }).filter(d => d.isDirectory());
for (const dir of dirs) {
const fullPath = path.join(<string>basePath, dir.name);
const id = crypto.createHash('sha1').update(fullPath).digest('hex');
const now = Date.now();
const existing = await queryOne(`SELECT id FROM local_entries WHERE id = ?`, [id], 'local_library');
if (existing) {
await run(`UPDATE local_entries SET last_scan = ? WHERE id = ?`, [now, id], 'local_library');
await run(`DELETE FROM local_files WHERE entry_id = ?`, [id], 'local_library');
} else {
await run(
`INSERT INTO local_entries (id, type, path, folder_name, last_scan) VALUES (?, ?, ?, ?, ?)`,
[id, type, fullPath, dir.name, now],
'local_library'
);
}
const files = fs.readdirSync(fullPath, { withFileTypes: true })
.filter(f =>
f.isFile() ||
(type === 'manga' && f.isDirectory())
)
.sort((a, b) => a.name.localeCompare(b.name));
let unit = 1;
for (const file of files) {
await run(
`INSERT INTO local_files (id, entry_id, file_path, unit_number)
VALUES (?, ?, ?, ?)`,
[crypto.randomUUID(), id, path.join(fullPath, file.name), unit],
'local_library'
);
unit++;
}
}
}
return { status: 'OK' };
} catch (err) {
return reply.status(500).send({ error: 'FAILED_TO_SCAN_LIBRARY' }); return reply.status(500).send({ error: 'FAILED_TO_SCAN_LIBRARY' });
} }
} }
@@ -137,9 +31,8 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue
export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try { try {
const { type } = request.params; const { type } = request.params;
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library'); const entries = await service.getEntriesByType(type);
return entries;
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
} catch { } catch {
return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' }); return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' });
} }
@@ -148,27 +41,13 @@ export async function listEntries(request: FastifyRequest<{ Params: Params }>, r
export async function getEntry(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { export async function getEntry(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try { try {
const { type, id } = request.params as { type: string, id: string }; const { type, id } = request.params as { type: string, id: string };
const entry = await service.getEntryDetails(type, id);
const entry = await queryOne(
`SELECT * FROM local_entries WHERE matched_id = ? AND type = ?`,
[Number(id), type],
'local_library'
);
if (!entry) { if (!entry) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
} }
const [details, files] = await Promise.all([ return entry;
resolveEntryMetadata(entry, type),
queryAll(
`SELECT id, file_path, unit_number FROM local_files WHERE entry_id = ? ORDER BY unit_number ASC`,
[id],
'local_library'
)
]);
return { ...details, files };
} catch { } catch {
return reply.status(500).send({ error: 'FAILED_TO_GET_ENTRY' }); return reply.status(500).send({ error: 'FAILED_TO_GET_ENTRY' });
} }
@@ -177,31 +56,26 @@ export async function getEntry(request: FastifyRequest<{ Params: Params }>, repl
export async function streamUnit(request: FastifyRequest, reply: FastifyReply) { export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
const { id, unit } = request.params as any; const { id, unit } = request.params as any;
const file = await queryOne( const fileInfo = await service.getFileForStreaming(id, unit);
`SELECT file_path FROM local_files WHERE entry_id = ? AND unit_number = ?`,
[id, unit],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) { if (!fileInfo) {
return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
} }
const stat = fs.statSync(file.file_path); const { filePath, stat } = fileInfo;
const range = request.headers.range; const range = request.headers.range;
if (!range) { if (!range) {
reply reply
.header('Content-Length', stat.size) .header('Content-Length', stat.size)
.header('Content-Type', 'video/mp4'); .header('Content-Type', 'video/mp4');
return fs.createReadStream(file.file_path); return fs.createReadStream(filePath);
} }
const parts = range.replace(/bytes=/, '').split('-'); const parts = range.replace(/bytes=/, '').split('-');
const start = Number(parts[0]); const start = Number(parts[0]);
const end = parts[1] ? Number(parts[1]) : stat.size - 1; const end = parts[1] ? Number(parts[1]) : stat.size - 1;
// Validate range values
if ( if (
Number.isNaN(start) || Number.isNaN(start) ||
Number.isNaN(end) || Number.isNaN(end) ||
@@ -222,14 +96,9 @@ export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
.header('Content-Length', contentLength) .header('Content-Length', contentLength)
.header('Content-Type', 'video/mp4'); .header('Content-Type', 'video/mp4');
return fs.createReadStream(file.file_path, { start, end }); return fs.createReadStream(filePath, { start, end });
} }
type MatchBody = {
source: 'anilist';
matched_id: number | null;
};
export async function matchEntry( export async function matchEntry(
request: FastifyRequest<{ Body: MatchBody }>, request: FastifyRequest<{ Body: MatchBody }>,
reply: FastifyReply reply: FastifyReply
@@ -237,134 +106,25 @@ export async function matchEntry(
const { id, type } = request.params as any; const { id, type } = request.params as any;
const { source, matched_id } = request.body; const { source, matched_id } = request.body;
const entry = await queryOne( const result = await service.updateEntryMatch(id, type, source, matched_id);
`SELECT id FROM local_entries WHERE id = ? AND type = ?`,
[id, type],
'local_library'
);
if (!entry) { if (!result) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
} }
await run( return result;
`UPDATE local_entries
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) { export async function getUnits(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try { try {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const units = await service.getEntryUnits(id);
const entry = await queryOne( if (!units) {
`SELECT id, type, matched_id FROM local_entries WHERE matched_id = ?`,
[Number(id)],
'local_library'
);
if (!entry) {
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
} }
// Obtener todos los archivos/unidades ordenados return units;
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'
);
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
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 {
entry_id: entry.id,
matched_id: entry.matched_id,
type: entry.type,
total: units.length,
units
};
} catch (err) { } catch (err) {
console.error('Error getting units:', err); console.error('Error getting units:', err);
return reply.status(500).send({ error: 'FAILED_TO_GET_UNITS' }); return reply.status(500).send({ error: 'FAILED_TO_GET_UNITS' });
@@ -374,169 +134,52 @@ export async function getUnits(request: FastifyRequest<{ Params: Params }>, repl
export async function getManifest(request: FastifyRequest, reply: FastifyReply) { export async function getManifest(request: FastifyRequest, reply: FastifyReply) {
const { unitId } = request.params as any; const { unitId } = request.params as any;
const file = await queryOne( try {
`SELECT file_path FROM local_files WHERE id = ?`, const manifest = await service.getUnitManifest(unitId);
[unitId],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) { if (!manifest) {
return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
}
return manifest;
} catch (err: any) {
if (err.message === 'UNSUPPORTED_FORMAT') {
return reply.status(400).send({ error: 'UNSUPPORTED_FORMAT' });
}
return reply.status(500).send({ error: 'FAILED_TO_GET_MANIFEST' });
} }
const ext = path.extname(file.file_path).toLowerCase();
// ===== MANGA =====
if (['.cbz', '.cbr', '.zip'].includes(ext)) {
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) => ({
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 getPage(request: FastifyRequest, reply: FastifyReply) { export async function getPage(request: FastifyRequest, reply: FastifyReply) {
const { unitId, resId } = request.params as any; const { unitId, resId } = request.params as any;
const file = await queryOne( const resource = await service.getUnitResource(unitId, resId);
`SELECT file_path FROM local_files WHERE id = ?`,
[unitId],
'local_library'
);
if (!file) return reply.status(404).send(); if (!resource) {
return reply.status(404).send();
const ext = path.extname(file.file_path).toLowerCase();
// ===== 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[Number(resId)];
if (!entry) return reply.status(404).send();
return reply
.header('Content-Type', 'image/jpeg')
.send(entry.getData());
} }
if (fs.statSync(file.file_path).isDirectory()) { if (resource.type === 'image') {
const images = fs.readdirSync(file.file_path) if (resource.data) {
.filter(f => /\.(jpg|jpeg|png|webp)$/i.test(f)) return reply
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); .header('Content-Type', 'image/jpeg')
.send(resource.data);
}
const img = images[Number(resId)]; if (resource.path && resource.size) {
if (!img) return reply.status(404).send(); reply
.header('Content-Length', resource.size)
.header('Content-Type', 'image/jpeg');
const imgPath = path.join(file.file_path, img); return fs.createReadStream(resource.path);
const stat = fs.statSync(imgPath); }
reply
.header('Content-Length', stat.size)
.header('Content-Type', 'image/jpeg');
return fs.createReadStream(imgPath);
} }
if (ext === '.epub') { if (resource.type === 'html') {
const html = await parseEpubToHtml(file.file_path);
return reply return reply
.header('Content-Type', 'text/html; charset=utf-8') .header('Content-Type', 'text/html; charset=utf-8')
.send(html); .send(resource.data);
}
// ===== 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(); 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

@@ -0,0 +1,454 @@
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 } from "../books/books.service";
import AdmZip from 'adm-zip';
import EPub from 'epub';
const MANGA_IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
const MANGA_ARCHIVES = ['.cbz', '.cbr', '.zip'];
const NOVEL_EXTS = ['.epub', '.pdf', '.txt', '.md', '.docx', '.mobi'];
export async function resolveEntryMetadata(entry: any, type: string) {
let metadata = null;
let matchedId = entry.matched_id;
if (!matchedId) {
const query = entry.folder_name;
const results = type === 'anime'
? await searchAnimeLocal(query)
: await searchBooksAniList(query);
let picked = null;
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
SET matched_id = ?, matched_source = 'anilist'
WHERE id = ?`,
[matchedId, entry.id],
'local_library'
);
}
}
if (matchedId) {
metadata = type === 'anime'
? await getAnimeById(matchedId)
: await getBookById(matchedId);
}
return {
id: entry.id,
type: entry.type,
matched: !!matchedId,
metadata
};
}
export async function performLibraryScan(mode: 'full' | 'incremental' = 'incremental') {
const config = loadConfig();
if (!config.library) {
throw new Error('NO_LIBRARY_CONFIGURED');
}
if (mode === 'full') {
await run(`DELETE FROM local_files`, [], 'local_library');
await run(`DELETE FROM local_entries`, [], 'local_library');
}
for (const [type, basePath] of Object.entries(config.library)) {
if (!basePath || !fs.existsSync(<PathLike>basePath)) continue;
const dirs = fs.readdirSync(<string>basePath, { withFileTypes: true }).filter(d => d.isDirectory());
for (const dir of dirs) {
const fullPath = path.join(<string>basePath, dir.name);
const id = crypto.createHash('sha1').update(fullPath).digest('hex');
const now = Date.now();
const existing = await queryOne(`SELECT id FROM local_entries WHERE id = ?`, [id], 'local_library');
if (existing) {
await run(`UPDATE local_entries SET last_scan = ? WHERE id = ?`, [now, id], 'local_library');
await run(`DELETE FROM local_files WHERE entry_id = ?`, [id], 'local_library');
} else {
await run(
`INSERT INTO local_entries (id, type, path, folder_name, last_scan) VALUES (?, ?, ?, ?, ?)`,
[id, type, fullPath, dir.name, now],
'local_library'
);
}
const files = fs.readdirSync(fullPath, { withFileTypes: true })
.filter(f =>
f.isFile() ||
(type === 'manga' && f.isDirectory())
)
.sort((a, b) => a.name.localeCompare(b.name));
let unit = 1;
for (const file of files) {
await run(
`INSERT INTO local_files (id, entry_id, file_path, unit_number)
VALUES (?, ?, ?, ?)`,
[crypto.randomUUID(), id, path.join(fullPath, file.name), unit],
'local_library'
);
unit++;
}
}
}
return { status: 'OK' };
}
export async function getEntriesByType(type: string) {
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library');
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
}
export async function getEntryDetails(type: string, id: string) {
const entry = await queryOne(
`SELECT * FROM local_entries WHERE matched_id = ? AND type = ?`,
[Number(id), type],
'local_library'
);
if (!entry) {
return null;
}
const [details, files] = await Promise.all([
resolveEntryMetadata(entry, type),
queryAll(
`SELECT id, file_path, unit_number FROM local_files WHERE entry_id = ? ORDER BY unit_number ASC`,
[id],
'local_library'
)
]);
return { ...details, files };
}
export async function getFileForStreaming(id: string, unit: string) {
const file = await queryOne(
`SELECT file_path FROM local_files WHERE entry_id = ? AND unit_number = ?`,
[id, unit],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) {
return null;
}
return {
filePath: file.file_path,
stat: fs.statSync(file.file_path)
};
}
export async function updateEntryMatch(id: string, type: string, source: string, matchedId: number | null) {
const entry = await queryOne(
`SELECT id FROM local_entries WHERE id = ? AND type = ?`,
[id, type],
'local_library'
);
if (!entry) {
return null;
}
await run(
`UPDATE local_entries
SET matched_source = ?, matched_id = ?
WHERE id = ?`,
[source, matchedId, id],
'local_library'
);
return { status: 'OK', matched: !!matchedId };
}
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()));
}
export async function getEntryUnits(id: string) {
const entry = await queryOne(
`SELECT id, type, matched_id FROM local_entries WHERE matched_id = ?`,
[Number(id)],
'local_library'
);
if (!entry) {
return null;
}
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'
);
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();
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 {
entry_id: entry.id,
matched_id: entry.matched_id,
type: entry.type,
total: units.length,
units
};
}
export async function getUnitManifest(unitId: string) {
const file = await queryOne(
`SELECT file_path FROM local_files WHERE id = ?`,
[unitId],
'local_library'
);
if (!file || !fs.existsSync(file.file_path)) {
return null;
}
const ext = path.extname(file.file_path).toLowerCase();
if (['.cbz', '.cbr', '.zip'].includes(ext)) {
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) => ({
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
};
}
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`
};
}
throw new Error('UNSUPPORTED_FORMAT');
}
export async function getUnitResource(unitId: string, resId: string) {
const file = await queryOne(
`SELECT file_path FROM local_files WHERE id = ?`,
[unitId],
'local_library'
);
if (!file) return null;
const ext = path.extname(file.file_path).toLowerCase();
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[Number(resId)];
if (!entry) return null;
return {
type: 'image',
data: 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 null;
const imgPath = path.join(file.file_path, img);
const stat = fs.statSync(imgPath);
return {
type: 'image',
path: imgPath,
size: stat.size
};
}
if (ext === '.epub') {
const html = await parseEpubToHtml(file.file_path);
return {
type: 'html',
data: html
};
}
if (['.txt', '.md'].includes(ext)) {
const text = fs.readFileSync(file.file_path, 'utf8');
return {
type: 'html',
data: `<div class="ln-content"><pre>${text}</pre></div>`
};
}
return null;
}
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();
});
}