import { FastifyRequest, FastifyReply } from 'fastify'; import { getConfig as loadConfig, setConfig as saveConfig } from '../../shared/config.js'; import { queryOne, queryAll, run } from '../../shared/database.js'; import crypto from 'crypto'; import fs from "fs"; import { PathLike } from "node:fs"; import path from "path"; import {getAnimeById, searchAnimeLocal} from "../anime/anime.service"; import {getBookById, searchBooksAniList, searchBooksLocal} from "../books/books.service"; import AdmZip from 'adm-zip'; type SetConfigBody = { library?: { anime?: string | null; manga?: string | null; novels?: string | null; }; }; type ScanQuery = { mode?: 'full' | 'incremental'; }; type Params = { type: 'anime' | 'manga' | 'novels'; id?: string; }; 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); const first = results?.[0]; if (first?.id) { matchedId = first.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) { try { const mode = request.query.mode || 'incremental'; const config = loadConfig(); if (!config.library) { 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(basePath)) continue; const dirs = fs.readdirSync(basePath, { withFileTypes: true }).filter(d => d.isDirectory()); for (const dir of dirs) { const fullPath = path.join(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()) .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' }); } } export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { try { const { type } = request.params; const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library'); return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type))); } catch { return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' }); } } export async function getEntry(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { try { const { type, id } = request.params as { 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 reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); } 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 }; } catch { return reply.status(500).send({ error: 'FAILED_TO_GET_ENTRY' }); } } export async function streamUnit(request: FastifyRequest, reply: FastifyReply) { const { id, unit } = request.params as any; 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 reply.status(404).send({ error: 'FILE_NOT_FOUND' }); } const stat = fs.statSync(file.file_path); const range = request.headers.range; if (!range) { reply .header('Content-Length', stat.size) .header('Content-Type', 'video/mp4'); return fs.createReadStream(file.file_path); } const parts = range.replace(/bytes=/, '').split('-'); const start = Number(parts[0]); let end = parts[1] ? Number(parts[1]) : stat.size - 1; if ( Number.isNaN(start) || Number.isNaN(end) || start < 0 || end < start || end >= stat.size ) { end = stat.size - 1; } reply .status(206) .header('Content-Range', `bytes ${start}-${end}/${stat.size}`) .header('Accept-Ranges', 'bytes') .header('Content-Length', end - start + 1) .header('Content-Type', 'video/mp4'); return fs.createReadStream(file.file_path, { start, end }); } type MatchBody = { source: 'anilist'; matched_id: number | null; }; export async function matchEntry( request: FastifyRequest<{ Body: MatchBody }>, reply: FastifyReply ) { const { id, type } = request.params as any; const { source, matched_id } = request.body; const entry = await queryOne( `SELECT id FROM local_entries WHERE id = ? AND type = ?`, [id, type], 'local_library' ); if (!entry) { return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); } await run( `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 ) { try { const { type, id } = request.params as { type: string, id: string }; // Buscar la entrada por matched_id const entry = await queryOne( `SELECT id, type, matched_id FROM local_entries WHERE matched_id = ? AND type = ?`, [Number(id), type], 'local_library' ); if (!entry) { return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); } // Obtener todos los archivos/unidades ordenados 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' ); // Formatear la respuesta según el tipo const units = files.map((file: any) => { const fileName = path.basename(file.file_path); const fileExt = path.extname(file.file_path).toLowerCase(); // Detectar si es un archivo comprimido (capítulo único) o carpeta const isDirectory = fs.existsSync(file.file_path) && fs.statSync(file.file_path).isDirectory(); return { id: file.id, number: file.unit_number, name: fileName, type: type === 'anime' ? 'episode' : 'chapter', format: fileExt === '.cbz' ? 'cbz' : 'file', path: file.file_path }; }); return { entry_id: entry.id, matched_id: entry.matched_id, type: entry.type, total: units.length, units }; } catch (err) { console.error('Error getting units:', err); return reply.status(500).send({ error: 'FAILED_TO_GET_UNITS' }); } } export async function getCbzPages(request: FastifyRequest, reply: FastifyReply) { const { unitId } = request.params as any; const file = await queryOne( `SELECT file_path FROM local_files WHERE id = ?`, [unitId], 'local_library' ); if (!file || !fs.existsSync(file.file_path)) { return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); } 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) => `/api/library/manga/cbz/${unitId}/page/${i}` ); return { pages }; } export async function getCbzPage(request: FastifyRequest, reply: FastifyReply) { const { unitId, page } = request.params as any; const file = await queryOne( `SELECT file_path FROM local_files WHERE id = ?`, [unitId], 'local_library' ); if (!file) return reply.status(404).send(); const zip = new AdmZip(file.file_path); const images = zip.getEntries() .filter(e => !e.isDirectory && /\.(jpg|jpeg|png|webp)$/i.test(e.entryName)) .sort((a, b) => a.entryName.localeCompare(b.entryName, undefined, { numeric: true })); const entry = images[page]; if (!entry) return reply.status(404).send(); reply .header('Content-Type', 'image/jpeg') .send(entry.getData()); }