358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
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(<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())
|
|
.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());
|
|
}
|