organised local library backend
This commit is contained in:
@@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
454
desktop/src/api/local/local.service.ts
Normal file
454
desktop/src/api/local/local.service.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
454
docker/src/api/local/local.service.ts
Normal file
454
docker/src/api/local/local.service.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user