From c28948f6e997fc7bf1385d8da13cc340b5669654 Mon Sep 17 00:00:00 2001 From: lenafx Date: Sun, 28 Dec 2025 18:55:16 +0100 Subject: [PATCH] organised local library backend --- desktop/src/api/local/local.controller.ts | 467 +++------------------- desktop/src/api/local/local.service.ts | 454 +++++++++++++++++++++ docker/src/api/local/local.controller.ts | 467 +++------------------- docker/src/api/local/local.service.ts | 454 +++++++++++++++++++++ 4 files changed, 1018 insertions(+), 824 deletions(-) create mode 100644 desktop/src/api/local/local.service.ts create mode 100644 docker/src/api/local/local.service.ts diff --git a/desktop/src/api/local/local.controller.ts b/desktop/src/api/local/local.controller.ts index 908d669..eb88b34 100644 --- a/desktop/src/api/local/local.controller.ts +++ b/desktop/src/api/local/local.controller.ts @@ -1,14 +1,6 @@ -import { FastifyRequest, FastifyReply } from 'fastify'; -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'; +import {FastifyReply, FastifyRequest} from 'fastify'; +import fs from 'fs'; +import * as service from './local.service'; type ScanQuery = { mode?: 'full' | 'incremental'; @@ -19,117 +11,19 @@ type Params = { 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); - - 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 - }; -} +type MatchBody = { + source: 'anilist'; + matched_id: number | null; +}; export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) { try { const mode = request.query.mode || 'incremental'; - const config = loadConfig(); - - if (!config.library) { + return await service.performLibraryScan(mode); + } catch (err: any) { + if (err.message === '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(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() || - (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' }); } } @@ -137,9 +31,8 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue 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))); + const entries = await service.getEntriesByType(type); + return entries; } catch { 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) { 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' - ); + const entry = await service.getEntryDetails(type, id); 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 }; + return entry; } catch { 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) { 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' - ); + const fileInfo = await service.getFileForStreaming(id, unit); - if (!file || !fs.existsSync(file.file_path)) { + if (!fileInfo) { 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; if (!range) { reply .header('Content-Length', stat.size) .header('Content-Type', 'video/mp4'); - return fs.createReadStream(file.file_path); + return fs.createReadStream(filePath); } const parts = range.replace(/bytes=/, '').split('-'); const start = Number(parts[0]); const end = parts[1] ? Number(parts[1]) : stat.size - 1; - // Validate range values if ( Number.isNaN(start) || Number.isNaN(end) || @@ -222,14 +96,9 @@ export async function streamUnit(request: FastifyRequest, reply: FastifyReply) { .header('Content-Length', contentLength) .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( request: FastifyRequest<{ Body: MatchBody }>, reply: FastifyReply @@ -237,134 +106,25 @@ export async function matchEntry( 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' - ); + const result = await service.updateEntryMatch(id, type, source, matched_id); - if (!entry) { + if (!result) { 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 }; + return result; } export async function getUnits(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { try { const { id } = request.params as { id: string }; + const units = await service.getEntryUnits(id); - const entry = await queryOne( - `SELECT id, type, matched_id FROM local_entries WHERE matched_id = ?`, - [Number(id)], - 'local_library' - ); - - if (!entry) { + if (!units) { 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' - ); - - 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 - }; + return units; } catch (err) { console.error('Error getting units:', err); 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) { const { unitId } = request.params as any; - const file = await queryOne( - `SELECT file_path FROM local_files WHERE id = ?`, - [unitId], - 'local_library' - ); + try { + const manifest = await service.getUnitManifest(unitId); - if (!file || !fs.existsSync(file.file_path)) { - return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); + if (!manifest) { + 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) { const { unitId, resId } = request.params as any; - const file = await queryOne( - `SELECT file_path FROM local_files WHERE id = ?`, - [unitId], - 'local_library' - ); + const resource = await service.getUnitResource(unitId, resId); - if (!file) 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 (!resource) { + return reply.status(404).send(); } - 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 })); + if (resource.type === 'image') { + if (resource.data) { + return reply + .header('Content-Type', 'image/jpeg') + .send(resource.data); + } - const img = images[Number(resId)]; - if (!img) return reply.status(404).send(); + if (resource.path && resource.size) { + reply + .header('Content-Length', resource.size) + .header('Content-Type', 'image/jpeg'); - const imgPath = path.join(file.file_path, img); - const stat = fs.statSync(imgPath); - - reply - .header('Content-Length', stat.size) - .header('Content-Type', 'image/jpeg'); - - return fs.createReadStream(imgPath); + return fs.createReadStream(resource.path); + } } - if (ext === '.epub') { - const html = await parseEpubToHtml(file.file_path); - + if (resource.type === 'html') { return reply .header('Content-Type', 'text/html; charset=utf-8') - .send(html); - } - - // ===== TXT / MD ===== - if (['.txt', '.md'].includes(ext)) { - const text = fs.readFileSync(file.file_path, 'utf8'); - - return reply - .header('Content-Type', 'text/html; charset=utf-8') - .send(`
${text}
`); + .send(resource.data); } return reply.status(400).send(); -} - -function parseEpubToHtml(filePath: string): Promise { - 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((res, rej) => { - epub.getChapter(id, (err, text) => { - if (err) rej(err); - else res(text); - }); - }); - - html += `
${chapter}
`; - } - - resolve(html); - }); - - epub.on('error', reject); - epub.parse(); - }); -} +} \ No newline at end of file diff --git a/desktop/src/api/local/local.service.ts b/desktop/src/api/local/local.service.ts new file mode 100644 index 0000000..1d90844 --- /dev/null +++ b/desktop/src/api/local/local.service.ts @@ -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(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() || + (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: `
${text}
` + }; + } + + return null; +} + +function parseEpubToHtml(filePath: string): Promise { + 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((res, rej) => { + epub.getChapter(id, (err, text) => { + if (err) rej(err); + else res(text); + }); + }); + + html += `
${chapter}
`; + } + + resolve(html); + }); + + epub.on('error', reject); + epub.parse(); + }); +} \ No newline at end of file diff --git a/docker/src/api/local/local.controller.ts b/docker/src/api/local/local.controller.ts index 908d669..eb88b34 100644 --- a/docker/src/api/local/local.controller.ts +++ b/docker/src/api/local/local.controller.ts @@ -1,14 +1,6 @@ -import { FastifyRequest, FastifyReply } from 'fastify'; -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'; +import {FastifyReply, FastifyRequest} from 'fastify'; +import fs from 'fs'; +import * as service from './local.service'; type ScanQuery = { mode?: 'full' | 'incremental'; @@ -19,117 +11,19 @@ type Params = { 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); - - 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 - }; -} +type MatchBody = { + source: 'anilist'; + matched_id: number | null; +}; export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) { try { const mode = request.query.mode || 'incremental'; - const config = loadConfig(); - - if (!config.library) { + return await service.performLibraryScan(mode); + } catch (err: any) { + if (err.message === '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(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() || - (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' }); } } @@ -137,9 +31,8 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue 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))); + const entries = await service.getEntriesByType(type); + return entries; } catch { 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) { 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' - ); + const entry = await service.getEntryDetails(type, id); 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 }; + return entry; } catch { 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) { 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' - ); + const fileInfo = await service.getFileForStreaming(id, unit); - if (!file || !fs.existsSync(file.file_path)) { + if (!fileInfo) { 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; if (!range) { reply .header('Content-Length', stat.size) .header('Content-Type', 'video/mp4'); - return fs.createReadStream(file.file_path); + return fs.createReadStream(filePath); } const parts = range.replace(/bytes=/, '').split('-'); const start = Number(parts[0]); const end = parts[1] ? Number(parts[1]) : stat.size - 1; - // Validate range values if ( Number.isNaN(start) || Number.isNaN(end) || @@ -222,14 +96,9 @@ export async function streamUnit(request: FastifyRequest, reply: FastifyReply) { .header('Content-Length', contentLength) .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( request: FastifyRequest<{ Body: MatchBody }>, reply: FastifyReply @@ -237,134 +106,25 @@ export async function matchEntry( 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' - ); + const result = await service.updateEntryMatch(id, type, source, matched_id); - if (!entry) { + if (!result) { 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 }; + return result; } export async function getUnits(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { try { const { id } = request.params as { id: string }; + const units = await service.getEntryUnits(id); - const entry = await queryOne( - `SELECT id, type, matched_id FROM local_entries WHERE matched_id = ?`, - [Number(id)], - 'local_library' - ); - - if (!entry) { + if (!units) { 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' - ); - - 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 - }; + return units; } catch (err) { console.error('Error getting units:', err); 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) { const { unitId } = request.params as any; - const file = await queryOne( - `SELECT file_path FROM local_files WHERE id = ?`, - [unitId], - 'local_library' - ); + try { + const manifest = await service.getUnitManifest(unitId); - if (!file || !fs.existsSync(file.file_path)) { - return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); + if (!manifest) { + 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) { const { unitId, resId } = request.params as any; - const file = await queryOne( - `SELECT file_path FROM local_files WHERE id = ?`, - [unitId], - 'local_library' - ); + const resource = await service.getUnitResource(unitId, resId); - if (!file) 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 (!resource) { + return reply.status(404).send(); } - 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 })); + if (resource.type === 'image') { + if (resource.data) { + return reply + .header('Content-Type', 'image/jpeg') + .send(resource.data); + } - const img = images[Number(resId)]; - if (!img) return reply.status(404).send(); + if (resource.path && resource.size) { + reply + .header('Content-Length', resource.size) + .header('Content-Type', 'image/jpeg'); - const imgPath = path.join(file.file_path, img); - const stat = fs.statSync(imgPath); - - reply - .header('Content-Length', stat.size) - .header('Content-Type', 'image/jpeg'); - - return fs.createReadStream(imgPath); + return fs.createReadStream(resource.path); + } } - if (ext === '.epub') { - const html = await parseEpubToHtml(file.file_path); - + if (resource.type === 'html') { return reply .header('Content-Type', 'text/html; charset=utf-8') - .send(html); - } - - // ===== TXT / MD ===== - if (['.txt', '.md'].includes(ext)) { - const text = fs.readFileSync(file.file_path, 'utf8'); - - return reply - .header('Content-Type', 'text/html; charset=utf-8') - .send(`
${text}
`); + .send(resource.data); } return reply.status(400).send(); -} - -function parseEpubToHtml(filePath: string): Promise { - 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((res, rej) => { - epub.getChapter(id, (err, text) => { - if (err) rej(err); - else res(text); - }); - }); - - html += `
${chapter}
`; - } - - resolve(html); - }); - - epub.on('error', reject); - epub.parse(); - }); -} +} \ No newline at end of file diff --git a/docker/src/api/local/local.service.ts b/docker/src/api/local/local.service.ts new file mode 100644 index 0000000..1d90844 --- /dev/null +++ b/docker/src/api/local/local.service.ts @@ -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(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() || + (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: `
${text}
` + }; + } + + return null; +} + +function parseEpubToHtml(filePath: string): Promise { + 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((res, rej) => { + epub.getChapter(id, (err, text) => { + if (err) rej(err); + else res(text); + }); + }); + + html += `
${chapter}
`; + } + + resolve(html); + }); + + epub.on('error', reject); + epub.parse(); + }); +} \ No newline at end of file