added local manga, todo: novels
This commit is contained in:
21
desktop/package-lock.json
generated
21
desktop/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/static": "^8.3.0",
|
"@fastify/static": "^8.3.0",
|
||||||
"@xhayper/discord-rpc": "^1.3.0",
|
"@xhayper/discord-rpc": "^1.3.0",
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
@@ -1509,6 +1511,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/adm-zip": {
|
||||||
|
"version": "0.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz",
|
||||||
|
"integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/bcrypt": {
|
"node_modules/@types/bcrypt": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
@@ -1722,6 +1734,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adm-zip": {
|
||||||
|
"version": "0.5.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||||
|
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/static": "^8.3.0",
|
"@fastify/static": "^8.3.0",
|
||||||
"@xhayper/discord-rpc": "^1.3.0",
|
"@xhayper/discord-rpc": "^1.3.0",
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import fs from "fs";
|
|||||||
import { PathLike } from "node:fs";
|
import { PathLike } from "node:fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service";
|
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service";
|
||||||
import {getBookById, searchBooksLocal} from "../books/books.service";
|
import {getBookById, searchBooksAniList, searchBooksLocal} from "../books/books.service";
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
|
||||||
type SetConfigBody = {
|
type SetConfigBody = {
|
||||||
library?: {
|
library?: {
|
||||||
@@ -34,7 +35,7 @@ async function resolveEntryMetadata(entry: any, type: string) {
|
|||||||
|
|
||||||
const results = type === 'anime'
|
const results = type === 'anime'
|
||||||
? await searchAnimeLocal(query)
|
? await searchAnimeLocal(query)
|
||||||
: await searchBooksLocal(query);
|
: await searchBooksAniList(query);
|
||||||
|
|
||||||
const first = results?.[0];
|
const first = results?.[0];
|
||||||
|
|
||||||
@@ -245,3 +246,112 @@ export async function matchEntry(
|
|||||||
|
|
||||||
return { status: 'OK', matched: !!matched_id };
|
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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ async function localRoutes(fastify: FastifyInstance) {
|
|||||||
fastify.get('/library/:type/:id', controller.getEntry);
|
fastify.get('/library/:type/:id', controller.getEntry);
|
||||||
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
||||||
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
||||||
|
fastify.get('/library/:type/:id/units', controller.getUnits);
|
||||||
|
fastify.get('/library/:type/cbz/:unitId/pages', controller.getCbzPages);
|
||||||
|
fastify.get('/library/:type/cbz/:unitId/page/:page', controller.getCbzPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default localRoutes;
|
export default localRoutes;
|
||||||
@@ -7,7 +7,7 @@ let allChapters = [];
|
|||||||
let filteredChapters = [];
|
let filteredChapters = [];
|
||||||
|
|
||||||
let availableExtensions = [];
|
let availableExtensions = [];
|
||||||
|
let isLocal = false;
|
||||||
const chapterPagination = Object.create(PaginationManager);
|
const chapterPagination = Object.create(PaginationManager);
|
||||||
chapterPagination.init(12, () => renderChapterTable());
|
chapterPagination.init(12, () => renderChapterTable());
|
||||||
|
|
||||||
@@ -16,6 +16,40 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
setupModalClickOutside();
|
setupModalClickOutside();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function checkLocalLibraryEntry() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/library/manga/${bookId}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.matched) {
|
||||||
|
isLocal = true;
|
||||||
|
const pill = document.getElementById('local-pill');
|
||||||
|
if (pill) {
|
||||||
|
pill.textContent = 'Local';
|
||||||
|
pill.style.display = 'inline-flex';
|
||||||
|
pill.style.background = 'rgba(34, 197, 94, 0.2)';
|
||||||
|
pill.style.color = '#22c55e';
|
||||||
|
pill.style.borderColor = 'rgba(34, 197, 94, 0.3)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error checking local status:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAsLocal() {
|
||||||
|
isLocal = true;
|
||||||
|
const pill = document.getElementById('local-pill');
|
||||||
|
if (pill) {
|
||||||
|
pill.textContent = 'Local';
|
||||||
|
pill.style.display = 'inline-flex';
|
||||||
|
pill.style.background = 'rgba(34, 197, 94, 0.2)';
|
||||||
|
pill.style.color = '#22c55e';
|
||||||
|
pill.style.borderColor = 'rgba(34, 197, 94, 0.3)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
const urlData = URLUtils.parseEntityPath('book');
|
const urlData = URLUtils.parseEntityPath('book');
|
||||||
@@ -27,7 +61,7 @@ async function init() {
|
|||||||
extensionName = urlData.extensionName;
|
extensionName = urlData.extensionName;
|
||||||
bookId = urlData.entityId;
|
bookId = urlData.entityId;
|
||||||
bookSlug = urlData.slug;
|
bookSlug = urlData.slug;
|
||||||
|
await checkLocalLibraryEntry();
|
||||||
await loadBookMetadata();
|
await loadBookMetadata();
|
||||||
|
|
||||||
await loadAvailableExtensions();
|
await loadAvailableExtensions();
|
||||||
@@ -173,32 +207,46 @@ async function loadChapters(targetProvider = null) {
|
|||||||
const tbody = document.getElementById('chapters-body');
|
const tbody = document.getElementById('chapters-body');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
// Si no se pasa provider, intentamos pillar el del select o el primero disponible
|
|
||||||
if (!targetProvider) {
|
if (!targetProvider) {
|
||||||
const select = document.getElementById('provider-filter');
|
const select = document.getElementById('provider-filter');
|
||||||
targetProvider = select ? select.value : (availableExtensions[0] || 'all');
|
targetProvider = select ? select.value : (availableExtensions[0] || 'all');
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Searching extension for chapters...</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Loading chapters...</td></tr>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const source = extensionName || 'anilist';
|
let fetchUrl;
|
||||||
// Añadimos el query param 'provider' para que el backend filtre
|
let isLocalRequest = targetProvider === 'local';
|
||||||
let fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
|
|
||||||
if (targetProvider !== 'all') {
|
if (isLocalRequest) {
|
||||||
fetchUrl += `&provider=${targetProvider}`;
|
// Nuevo endpoint para archivos locales
|
||||||
|
fetchUrl = `/api/library/manga/${bookId}/units`;
|
||||||
|
} else {
|
||||||
|
const source = extensionName || 'anilist';
|
||||||
|
fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
|
||||||
|
if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(fetchUrl);
|
const res = await fetch(fetchUrl);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
allChapters = data.chapters || [];
|
// Mapeo de datos: Si es local usamos 'units', si no, usamos 'chapters'
|
||||||
filteredChapters = [...allChapters];
|
if (isLocalRequest) {
|
||||||
|
allChapters = (data.units || []).map((unit, idx) => ({
|
||||||
|
number: unit.number,
|
||||||
|
title: unit.name,
|
||||||
|
provider: 'local',
|
||||||
|
index: idx, // ✅ índice (0,1,2…)
|
||||||
|
format: unit.format
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
allChapters = data.chapters || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredChapters = [...allChapters];
|
||||||
applyChapterFilter();
|
applyChapterFilter();
|
||||||
|
|
||||||
const totalEl = document.getElementById('total-chapters');
|
const totalEl = document.getElementById('total-chapters');
|
||||||
|
|
||||||
if (allChapters.length === 0) {
|
if (allChapters.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>';
|
||||||
if (totalEl) totalEl.innerText = "0 Found";
|
if (totalEl) totalEl.innerText = "0 Found";
|
||||||
@@ -208,7 +256,6 @@ async function loadChapters(targetProvider = null) {
|
|||||||
if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
|
if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
|
||||||
|
|
||||||
setupReadButton();
|
setupReadButton();
|
||||||
|
|
||||||
chapterPagination.setTotalItems(filteredChapters.length);
|
chapterPagination.setTotalItems(filteredChapters.length);
|
||||||
chapterPagination.reset();
|
chapterPagination.reset();
|
||||||
renderChapterTable();
|
renderChapterTable();
|
||||||
@@ -235,16 +282,26 @@ function applyChapterFilter() {
|
|||||||
|
|
||||||
function setupProviderFilter() {
|
function setupProviderFilter() {
|
||||||
const select = document.getElementById('provider-filter');
|
const select = document.getElementById('provider-filter');
|
||||||
if (!select || availableExtensions.length === 0) return;
|
if (!select) return;
|
||||||
|
|
||||||
select.style.display = 'inline-block';
|
select.style.display = 'inline-block';
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
// Opción para cargar todo
|
||||||
const allOpt = document.createElement('option');
|
const allOpt = document.createElement('option');
|
||||||
allOpt.value = 'all';
|
allOpt.value = 'all';
|
||||||
allOpt.innerText = 'Load All (Slower)';
|
allOpt.innerText = 'Load All (Slower)';
|
||||||
select.appendChild(allOpt);
|
select.appendChild(allOpt);
|
||||||
|
|
||||||
|
// NUEVO: Si es local, añadimos la opción 'local' al principio
|
||||||
|
if (isLocal) {
|
||||||
|
const localOpt = document.createElement('option');
|
||||||
|
localOpt.value = 'local';
|
||||||
|
localOpt.innerText = 'Local';
|
||||||
|
select.appendChild(localOpt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Añadir extensiones normales
|
||||||
availableExtensions.forEach(ext => {
|
availableExtensions.forEach(ext => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = ext;
|
opt.value = ext;
|
||||||
@@ -252,7 +309,10 @@ function setupProviderFilter() {
|
|||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (extensionName && availableExtensions.includes(extensionName)) {
|
// Lógica de selección automática
|
||||||
|
if (isLocal) {
|
||||||
|
select.value = 'local'; // Prioridad si es local
|
||||||
|
} else if (extensionName && availableExtensions.includes(extensionName)) {
|
||||||
select.value = extensionName;
|
select.value = extensionName;
|
||||||
} else if (availableExtensions.length > 0) {
|
} else if (availableExtensions.length > 0) {
|
||||||
select.value = availableExtensions[0];
|
select.value = availableExtensions[0];
|
||||||
@@ -314,7 +374,14 @@ function renderChapterTable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openReader(chapterId, provider) {
|
function openReader(chapterId, provider) {
|
||||||
window.location.href = URLUtils.buildReadUrl(bookId, chapterId, provider, extensionName);
|
const effectiveExtension = extensionName || 'anilist';
|
||||||
|
|
||||||
|
window.location.href = URLUtils.buildReadUrl(
|
||||||
|
bookId, // SIEMPRE anilist
|
||||||
|
chapterId, // número normal
|
||||||
|
provider, // 'local' o extensión
|
||||||
|
extensionName || 'anilist'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupModalClickOutside() {
|
function setupModalClickOutside() {
|
||||||
|
|||||||
@@ -129,11 +129,44 @@ async function loadChapter() {
|
|||||||
if (!source) {
|
if (!source) {
|
||||||
source = 'anilist';
|
source = 'anilist';
|
||||||
}
|
}
|
||||||
const newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
|
let newEndpoint;
|
||||||
|
|
||||||
|
if (provider === 'local') {
|
||||||
|
newEndpoint = `/api/library/manga/${bookId}/units`;
|
||||||
|
} else {
|
||||||
|
newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(newEndpoint);
|
const res = await fetch(newEndpoint);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (provider === 'local') {
|
||||||
|
const unit = data.units[Number(chapter)];
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
reader.innerHTML = '<div class="loading-container"><span>Chapter not found</span></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit.format === 'cbz') {
|
||||||
|
chapterLabel.textContent = unit.name; // ✅
|
||||||
|
document.title = unit.name;
|
||||||
|
const pagesRes = await fetch(
|
||||||
|
`/api/library/manga/cbz/${unit.id}/pages`
|
||||||
|
);
|
||||||
|
const pagesData = await pagesRes.json();
|
||||||
|
|
||||||
|
currentType = 'manga';
|
||||||
|
updateSettingsVisibility();
|
||||||
|
applyStyles();
|
||||||
|
|
||||||
|
currentPages = pagesData.pages.map(url => ({ url }));
|
||||||
|
reader.innerHTML = '';
|
||||||
|
loadManga(currentPages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (data.title) {
|
if (data.title) {
|
||||||
chapterLabel.textContent = data.title;
|
chapterLabel.textContent = data.title;
|
||||||
@@ -172,8 +205,13 @@ async function loadChapter() {
|
|||||||
reader.innerHTML = '';
|
reader.innerHTML = '';
|
||||||
|
|
||||||
if (data.type === 'manga') {
|
if (data.type === 'manga') {
|
||||||
currentPages = data.pages || [];
|
if (provider === 'local' && data.format === 'cbz') {
|
||||||
loadManga(currentPages);
|
currentPages = data.pages.map(url => ({ url }));
|
||||||
|
loadManga(currentPages);
|
||||||
|
} else {
|
||||||
|
currentPages = data.pages || [];
|
||||||
|
loadManga(currentPages);
|
||||||
|
}
|
||||||
} else if (data.type === 'ln') {
|
} else if (data.type === 'ln') {
|
||||||
loadLN(data.content);
|
loadLN(data.content);
|
||||||
}
|
}
|
||||||
@@ -293,7 +331,9 @@ function createImageElement(page, index) {
|
|||||||
img.className = 'page-img';
|
img.className = 'page-img';
|
||||||
img.dataset.index = index;
|
img.dataset.index = index;
|
||||||
|
|
||||||
const url = buildProxyUrl(page.url, page.headers);
|
const url = provider === 'local'
|
||||||
|
? page.url
|
||||||
|
: buildProxyUrl(page.url, page.headers);
|
||||||
const placeholder = "/public/assets/placeholder.svg";
|
const placeholder = "/public/assets/placeholder.svg";
|
||||||
|
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
|
|||||||
89
desktop/src/scripts/local-library-books.js
Normal file
89
desktop/src/scripts/local-library-books.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
let activeFilter = 'all';
|
||||||
|
let activeSort = 'az';
|
||||||
|
let isLocalMode = false;
|
||||||
|
let localEntries = [];
|
||||||
|
|
||||||
|
function toggleLibraryMode() {
|
||||||
|
isLocalMode = !isLocalMode;
|
||||||
|
const btn = document.getElementById('library-mode-btn');
|
||||||
|
const onlineContent = document.getElementById('online-content');
|
||||||
|
const localContent = document.getElementById('local-content');
|
||||||
|
|
||||||
|
if (isLocalMode) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
onlineContent.classList.add('hidden');
|
||||||
|
localContent.classList.remove('hidden');
|
||||||
|
loadLocalEntries();
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
onlineContent.classList.remove('hidden');
|
||||||
|
localContent.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalEntries() {
|
||||||
|
const grid = document.getElementById('local-entries-grid');
|
||||||
|
grid.innerHTML = '<div class="skeleton-card"></div>'.repeat(6);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cambiado a endpoint de libros
|
||||||
|
const response = await fetch('/api/library/manga');
|
||||||
|
const entries = await response.json();
|
||||||
|
localEntries = entries;
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 3rem;">No books found in your local library.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderLocalEntries(entries);
|
||||||
|
} catch (err) {
|
||||||
|
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-danger); padding: 3rem;">Error loading local books.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLocalEntries(entries) {
|
||||||
|
const grid = document.getElementById('local-entries-grid');
|
||||||
|
grid.innerHTML = entries.map(entry => {
|
||||||
|
const title = entry.metadata?.title?.romaji || entry.metadata?.title?.english || entry.id;
|
||||||
|
const cover = entry.metadata?.coverImage?.extraLarge || '/public/assets/placeholder.jpg';
|
||||||
|
const chapters = entry.metadata?.chapters || '??';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
|
||||||
|
<div class="card-img-wrap">
|
||||||
|
<img src="${cover}" alt="${title}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="local-card-info">
|
||||||
|
<div class="local-card-title">${title}</div>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
|
||||||
|
${chapters} Chapters
|
||||||
|
</p>
|
||||||
|
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
|
||||||
|
${entry.matched ? '● Linked' : '○ Unlinked'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanLocalLibrary() {
|
||||||
|
const btnText = document.getElementById('scan-text');
|
||||||
|
btnText.innerText = "Scanning...";
|
||||||
|
try {
|
||||||
|
// Asumiendo que el scan de libros usa este query param
|
||||||
|
const response = await fetch('/api/library/scan?mode=incremental', { method: 'POST' });
|
||||||
|
if (response.ok) {
|
||||||
|
await loadLocalEntries();
|
||||||
|
if (window.NotificationUtils) NotificationUtils.show('Library scanned!', 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.NotificationUtils) NotificationUtils.show('Scan failed', 'error');
|
||||||
|
} finally {
|
||||||
|
btnText.innerText = "Scan Library";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewLocalEntry(id) {
|
||||||
|
if (id) window.location.href = `/book/${id}`;
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@
|
|||||||
|
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div>
|
<div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div>
|
||||||
|
<div class="pill" id="local-pill" style="display: none; background: #8b5cf6;"></div>
|
||||||
<div class="pill score" id="score">--% Score</div>
|
<div class="pill score" id="score">--% Score</div>
|
||||||
<div class="pill" id="genres">Action</div>
|
<div class="pill" id="genres">Action</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||||
<script src="/src/scripts/titlebar.js"></script>
|
<script src="/src/scripts/titlebar.js"></script>
|
||||||
|
<link rel="stylesheet" href="/views/css/components/local-library.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="titlebar"> <div class="title-left">
|
<div id="titlebar"> <div class="title-left">
|
||||||
@@ -49,9 +50,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="library-mode-btn icon-only" id="library-mode-btn" onclick="toggleLibraryMode()" title="Switch library mode">
|
||||||
|
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<main id="local-content" class="hidden">
|
||||||
<main>
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title">Local Books Library</div>
|
||||||
|
<button class="btn-secondary" onclick="scanLocalLibrary()">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M21 12a9 9 0 1 1-9-9"/><path d="M21 3v6h-6"/>
|
||||||
|
</svg>
|
||||||
|
<span id="scan-text">Scan Library</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="local-filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<button class="filter-btn active" data-filter="all">All</button>
|
||||||
|
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<button class="filter-btn" data-sort="az">A–Z</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="local-library-grid" id="local-entries-grid">
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<main id="online-content">
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div class="section-title">Continue Reading</div>
|
<div class="section-title">Continue Reading</div>
|
||||||
@@ -100,7 +132,7 @@
|
|||||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||||
<script src="/src/scripts/utils/continue-watching-manager.js"></script>
|
<script src="/src/scripts/utils/continue-watching-manager.js"></script>
|
||||||
<script src="/src/scripts/books/books.js"></script>
|
<script src="/src/scripts/books/books.js"></script>
|
||||||
|
<script src="/src/scripts/local-library-books.js"></script>
|
||||||
<script src="/src/scripts/updateNotifier.js"></script>
|
<script src="/src/scripts/updateNotifier.js"></script>
|
||||||
<script src="/src/scripts/rpc-inapp.js"></script>
|
<script src="/src/scripts/rpc-inapp.js"></script>
|
||||||
<script src="/src/scripts/auth-guard.js"></script>
|
<script src="/src/scripts/auth-guard.js"></script>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import fs from "fs";
|
|||||||
import { PathLike } from "node:fs";
|
import { PathLike } from "node:fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service";
|
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service";
|
||||||
import {getBookById, searchBooksLocal} from "../books/books.service";
|
import {getBookById, searchBooksAniList, searchBooksLocal} from "../books/books.service";
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
|
||||||
type SetConfigBody = {
|
type SetConfigBody = {
|
||||||
library?: {
|
library?: {
|
||||||
@@ -34,7 +35,7 @@ async function resolveEntryMetadata(entry: any, type: string) {
|
|||||||
|
|
||||||
const results = type === 'anime'
|
const results = type === 'anime'
|
||||||
? await searchAnimeLocal(query)
|
? await searchAnimeLocal(query)
|
||||||
: await searchBooksLocal(query);
|
: await searchBooksAniList(query);
|
||||||
|
|
||||||
const first = results?.[0];
|
const first = results?.[0];
|
||||||
|
|
||||||
@@ -237,11 +238,120 @@ export async function matchEntry(
|
|||||||
|
|
||||||
await run(
|
await run(
|
||||||
`UPDATE local_entries
|
`UPDATE local_entries
|
||||||
SET matched_source = ?, matched_id = ?
|
SET matched_source = ?, matched_id = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
[source, matched_id, id],
|
[source, matched_id, id],
|
||||||
'local_library'
|
'local_library'
|
||||||
);
|
);
|
||||||
|
|
||||||
return { status: 'OK', matched: !!matched_id };
|
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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ async function localRoutes(fastify: FastifyInstance) {
|
|||||||
fastify.get('/library/:type/:id', controller.getEntry);
|
fastify.get('/library/:type/:id', controller.getEntry);
|
||||||
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
||||||
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
||||||
|
fastify.get('/library/:type/:id/units', controller.getUnits);
|
||||||
|
fastify.get('/library/:type/cbz/:unitId/pages', controller.getCbzPages);
|
||||||
|
fastify.get('/library/:type/cbz/:unitId/page/:page', controller.getCbzPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default localRoutes;
|
export default localRoutes;
|
||||||
@@ -7,7 +7,7 @@ let allChapters = [];
|
|||||||
let filteredChapters = [];
|
let filteredChapters = [];
|
||||||
|
|
||||||
let availableExtensions = [];
|
let availableExtensions = [];
|
||||||
|
let isLocal = false;
|
||||||
const chapterPagination = Object.create(PaginationManager);
|
const chapterPagination = Object.create(PaginationManager);
|
||||||
chapterPagination.init(12, () => renderChapterTable());
|
chapterPagination.init(12, () => renderChapterTable());
|
||||||
|
|
||||||
@@ -16,6 +16,40 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
setupModalClickOutside();
|
setupModalClickOutside();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function checkLocalLibraryEntry() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/library/manga/${bookId}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.matched) {
|
||||||
|
isLocal = true;
|
||||||
|
const pill = document.getElementById('local-pill');
|
||||||
|
if (pill) {
|
||||||
|
pill.textContent = 'Local';
|
||||||
|
pill.style.display = 'inline-flex';
|
||||||
|
pill.style.background = 'rgba(34, 197, 94, 0.2)';
|
||||||
|
pill.style.color = '#22c55e';
|
||||||
|
pill.style.borderColor = 'rgba(34, 197, 94, 0.3)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error checking local status:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAsLocal() {
|
||||||
|
isLocal = true;
|
||||||
|
const pill = document.getElementById('local-pill');
|
||||||
|
if (pill) {
|
||||||
|
pill.textContent = 'Local';
|
||||||
|
pill.style.display = 'inline-flex';
|
||||||
|
pill.style.background = 'rgba(34, 197, 94, 0.2)';
|
||||||
|
pill.style.color = '#22c55e';
|
||||||
|
pill.style.borderColor = 'rgba(34, 197, 94, 0.3)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
const urlData = URLUtils.parseEntityPath('book');
|
const urlData = URLUtils.parseEntityPath('book');
|
||||||
@@ -27,7 +61,7 @@ async function init() {
|
|||||||
extensionName = urlData.extensionName;
|
extensionName = urlData.extensionName;
|
||||||
bookId = urlData.entityId;
|
bookId = urlData.entityId;
|
||||||
bookSlug = urlData.slug;
|
bookSlug = urlData.slug;
|
||||||
|
await checkLocalLibraryEntry();
|
||||||
await loadBookMetadata();
|
await loadBookMetadata();
|
||||||
|
|
||||||
await loadAvailableExtensions();
|
await loadAvailableExtensions();
|
||||||
@@ -71,7 +105,6 @@ async function loadBookMetadata() {
|
|||||||
const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName);
|
const metadata = MediaMetadataUtils.formatBookData(raw, !!extensionName);
|
||||||
bookData.entry_type =
|
bookData.entry_type =
|
||||||
metadata.format === 'MANGA' ? 'MANGA' : 'NOVEL';
|
metadata.format === 'MANGA' ? 'MANGA' : 'NOVEL';
|
||||||
|
|
||||||
updatePageTitle(metadata.title);
|
updatePageTitle(metadata.title);
|
||||||
updateMetadata(metadata);
|
updateMetadata(metadata);
|
||||||
updateExtensionPill();
|
updateExtensionPill();
|
||||||
@@ -174,32 +207,46 @@ async function loadChapters(targetProvider = null) {
|
|||||||
const tbody = document.getElementById('chapters-body');
|
const tbody = document.getElementById('chapters-body');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
// Si no se pasa provider, intentamos pillar el del select o el primero disponible
|
|
||||||
if (!targetProvider) {
|
if (!targetProvider) {
|
||||||
const select = document.getElementById('provider-filter');
|
const select = document.getElementById('provider-filter');
|
||||||
targetProvider = select ? select.value : (availableExtensions[0] || 'all');
|
targetProvider = select ? select.value : (availableExtensions[0] || 'all');
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Searching extension for chapters...</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Loading chapters...</td></tr>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const source = extensionName || 'anilist';
|
let fetchUrl;
|
||||||
// Añadimos el query param 'provider' para que el backend filtre
|
let isLocalRequest = targetProvider === 'local';
|
||||||
let fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
|
|
||||||
if (targetProvider !== 'all') {
|
if (isLocalRequest) {
|
||||||
fetchUrl += `&provider=${targetProvider}`;
|
// Nuevo endpoint para archivos locales
|
||||||
|
fetchUrl = `/api/library/manga/${bookId}/units`;
|
||||||
|
} else {
|
||||||
|
const source = extensionName || 'anilist';
|
||||||
|
fetchUrl = `/api/book/${bookId}/chapters?source=${source}`;
|
||||||
|
if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(fetchUrl);
|
const res = await fetch(fetchUrl);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
allChapters = data.chapters || [];
|
// Mapeo de datos: Si es local usamos 'units', si no, usamos 'chapters'
|
||||||
filteredChapters = [...allChapters];
|
if (isLocalRequest) {
|
||||||
|
allChapters = (data.units || []).map((unit, idx) => ({
|
||||||
|
number: unit.number,
|
||||||
|
title: unit.name,
|
||||||
|
provider: 'local',
|
||||||
|
index: idx, // ✅ índice (0,1,2…)
|
||||||
|
format: unit.format
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
allChapters = data.chapters || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredChapters = [...allChapters];
|
||||||
applyChapterFilter();
|
applyChapterFilter();
|
||||||
|
|
||||||
const totalEl = document.getElementById('total-chapters');
|
const totalEl = document.getElementById('total-chapters');
|
||||||
|
|
||||||
if (allChapters.length === 0) {
|
if (allChapters.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found.</td></tr>';
|
||||||
if (totalEl) totalEl.innerText = "0 Found";
|
if (totalEl) totalEl.innerText = "0 Found";
|
||||||
@@ -209,7 +256,6 @@ async function loadChapters(targetProvider = null) {
|
|||||||
if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
|
if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
|
||||||
|
|
||||||
setupReadButton();
|
setupReadButton();
|
||||||
|
|
||||||
chapterPagination.setTotalItems(filteredChapters.length);
|
chapterPagination.setTotalItems(filteredChapters.length);
|
||||||
chapterPagination.reset();
|
chapterPagination.reset();
|
||||||
renderChapterTable();
|
renderChapterTable();
|
||||||
@@ -236,16 +282,26 @@ function applyChapterFilter() {
|
|||||||
|
|
||||||
function setupProviderFilter() {
|
function setupProviderFilter() {
|
||||||
const select = document.getElementById('provider-filter');
|
const select = document.getElementById('provider-filter');
|
||||||
if (!select || availableExtensions.length === 0) return;
|
if (!select) return;
|
||||||
|
|
||||||
select.style.display = 'inline-block';
|
select.style.display = 'inline-block';
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
// Opción para cargar todo
|
||||||
const allOpt = document.createElement('option');
|
const allOpt = document.createElement('option');
|
||||||
allOpt.value = 'all';
|
allOpt.value = 'all';
|
||||||
allOpt.innerText = 'Load All (Slower)';
|
allOpt.innerText = 'Load All (Slower)';
|
||||||
select.appendChild(allOpt);
|
select.appendChild(allOpt);
|
||||||
|
|
||||||
|
// NUEVO: Si es local, añadimos la opción 'local' al principio
|
||||||
|
if (isLocal) {
|
||||||
|
const localOpt = document.createElement('option');
|
||||||
|
localOpt.value = 'local';
|
||||||
|
localOpt.innerText = 'Local';
|
||||||
|
select.appendChild(localOpt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Añadir extensiones normales
|
||||||
availableExtensions.forEach(ext => {
|
availableExtensions.forEach(ext => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = ext;
|
opt.value = ext;
|
||||||
@@ -253,7 +309,10 @@ function setupProviderFilter() {
|
|||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (extensionName && availableExtensions.includes(extensionName)) {
|
// Lógica de selección automática
|
||||||
|
if (isLocal) {
|
||||||
|
select.value = 'local'; // Prioridad si es local
|
||||||
|
} else if (extensionName && availableExtensions.includes(extensionName)) {
|
||||||
select.value = extensionName;
|
select.value = extensionName;
|
||||||
} else if (availableExtensions.length > 0) {
|
} else if (availableExtensions.length > 0) {
|
||||||
select.value = availableExtensions[0];
|
select.value = availableExtensions[0];
|
||||||
@@ -315,7 +374,14 @@ function renderChapterTable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openReader(chapterId, provider) {
|
function openReader(chapterId, provider) {
|
||||||
window.location.href = URLUtils.buildReadUrl(bookId, chapterId, provider, extensionName);
|
const effectiveExtension = extensionName || 'anilist';
|
||||||
|
|
||||||
|
window.location.href = URLUtils.buildReadUrl(
|
||||||
|
bookId, // SIEMPRE anilist
|
||||||
|
chapterId, // número normal
|
||||||
|
provider, // 'local' o extensión
|
||||||
|
extensionName || 'anilist'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupModalClickOutside() {
|
function setupModalClickOutside() {
|
||||||
|
|||||||
@@ -129,11 +129,44 @@ async function loadChapter() {
|
|||||||
if (!source) {
|
if (!source) {
|
||||||
source = 'anilist';
|
source = 'anilist';
|
||||||
}
|
}
|
||||||
const newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
|
let newEndpoint;
|
||||||
|
|
||||||
|
if (provider === 'local') {
|
||||||
|
newEndpoint = `/api/library/manga/${bookId}/units`;
|
||||||
|
} else {
|
||||||
|
newEndpoint = `/api/book/${bookId}/${chapter}/${provider}?source=${source}`;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(newEndpoint);
|
const res = await fetch(newEndpoint);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (provider === 'local') {
|
||||||
|
const unit = data.units[Number(chapter)];
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
reader.innerHTML = '<div class="loading-container"><span>Chapter not found</span></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit.format === 'cbz') {
|
||||||
|
chapterLabel.textContent = unit.name; // ✅
|
||||||
|
document.title = unit.name;
|
||||||
|
const pagesRes = await fetch(
|
||||||
|
`/api/library/manga/cbz/${unit.id}/pages`
|
||||||
|
);
|
||||||
|
const pagesData = await pagesRes.json();
|
||||||
|
|
||||||
|
currentType = 'manga';
|
||||||
|
updateSettingsVisibility();
|
||||||
|
applyStyles();
|
||||||
|
|
||||||
|
currentPages = pagesData.pages.map(url => ({ url }));
|
||||||
|
reader.innerHTML = '';
|
||||||
|
loadManga(currentPages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (data.title) {
|
if (data.title) {
|
||||||
chapterLabel.textContent = data.title;
|
chapterLabel.textContent = data.title;
|
||||||
@@ -160,8 +193,13 @@ async function loadChapter() {
|
|||||||
reader.innerHTML = '';
|
reader.innerHTML = '';
|
||||||
|
|
||||||
if (data.type === 'manga') {
|
if (data.type === 'manga') {
|
||||||
currentPages = data.pages || [];
|
if (provider === 'local' && data.format === 'cbz') {
|
||||||
loadManga(currentPages);
|
currentPages = data.pages.map(url => ({ url }));
|
||||||
|
loadManga(currentPages);
|
||||||
|
} else {
|
||||||
|
currentPages = data.pages || [];
|
||||||
|
loadManga(currentPages);
|
||||||
|
}
|
||||||
} else if (data.type === 'ln') {
|
} else if (data.type === 'ln') {
|
||||||
loadLN(data.content);
|
loadLN(data.content);
|
||||||
}
|
}
|
||||||
@@ -281,7 +319,9 @@ function createImageElement(page, index) {
|
|||||||
img.className = 'page-img';
|
img.className = 'page-img';
|
||||||
img.dataset.index = index;
|
img.dataset.index = index;
|
||||||
|
|
||||||
const url = buildProxyUrl(page.url, page.headers);
|
const url = provider === 'local'
|
||||||
|
? page.url
|
||||||
|
: buildProxyUrl(page.url, page.headers);
|
||||||
const placeholder = "/public/assets/placeholder.svg";
|
const placeholder = "/public/assets/placeholder.svg";
|
||||||
|
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
|
|||||||
89
docker/src/scripts/local-library-books.js
Normal file
89
docker/src/scripts/local-library-books.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
let activeFilter = 'all';
|
||||||
|
let activeSort = 'az';
|
||||||
|
let isLocalMode = false;
|
||||||
|
let localEntries = [];
|
||||||
|
|
||||||
|
function toggleLibraryMode() {
|
||||||
|
isLocalMode = !isLocalMode;
|
||||||
|
const btn = document.getElementById('library-mode-btn');
|
||||||
|
const onlineContent = document.getElementById('online-content');
|
||||||
|
const localContent = document.getElementById('local-content');
|
||||||
|
|
||||||
|
if (isLocalMode) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
onlineContent.classList.add('hidden');
|
||||||
|
localContent.classList.remove('hidden');
|
||||||
|
loadLocalEntries();
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
onlineContent.classList.remove('hidden');
|
||||||
|
localContent.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalEntries() {
|
||||||
|
const grid = document.getElementById('local-entries-grid');
|
||||||
|
grid.innerHTML = '<div class="skeleton-card"></div>'.repeat(6);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cambiado a endpoint de libros
|
||||||
|
const response = await fetch('/api/library/manga');
|
||||||
|
const entries = await response.json();
|
||||||
|
localEntries = entries;
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 3rem;">No books found in your local library.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderLocalEntries(entries);
|
||||||
|
} catch (err) {
|
||||||
|
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-danger); padding: 3rem;">Error loading local books.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLocalEntries(entries) {
|
||||||
|
const grid = document.getElementById('local-entries-grid');
|
||||||
|
grid.innerHTML = entries.map(entry => {
|
||||||
|
const title = entry.metadata?.title?.romaji || entry.metadata?.title?.english || entry.id;
|
||||||
|
const cover = entry.metadata?.coverImage?.extraLarge || '/public/assets/placeholder.jpg';
|
||||||
|
const chapters = entry.metadata?.chapters || '??';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
|
||||||
|
<div class="card-img-wrap">
|
||||||
|
<img src="${cover}" alt="${title}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="local-card-info">
|
||||||
|
<div class="local-card-title">${title}</div>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
|
||||||
|
${chapters} Chapters
|
||||||
|
</p>
|
||||||
|
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
|
||||||
|
${entry.matched ? '● Linked' : '○ Unlinked'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanLocalLibrary() {
|
||||||
|
const btnText = document.getElementById('scan-text');
|
||||||
|
btnText.innerText = "Scanning...";
|
||||||
|
try {
|
||||||
|
// Asumiendo que el scan de libros usa este query param
|
||||||
|
const response = await fetch('/api/library/scan?mode=incremental', { method: 'POST' });
|
||||||
|
if (response.ok) {
|
||||||
|
await loadLocalEntries();
|
||||||
|
if (window.NotificationUtils) NotificationUtils.show('Library scanned!', 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.NotificationUtils) NotificationUtils.show('Scan failed', 'error');
|
||||||
|
} finally {
|
||||||
|
btnText.innerText = "Scan Library";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewLocalEntry(id) {
|
||||||
|
if (id) window.location.href = `/book/${id}`;
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@
|
|||||||
|
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div>
|
<div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div>
|
||||||
|
<div class="pill" id="local-pill" style="display: none; background: #8b5cf6;"></div>
|
||||||
<div class="pill score" id="score">--% Score</div>
|
<div class="pill score" id="score">--% Score</div>
|
||||||
<div class="pill" id="genres">Action</div>
|
<div class="pill" id="genres">Action</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/views/css/components/hero.css">
|
<link rel="stylesheet" href="/views/css/components/hero.css">
|
||||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
|
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
|
||||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||||
|
<link rel="stylesheet" href="/views/css/components/local-library.css">
|
||||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -36,9 +37,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="library-mode-btn icon-only" id="library-mode-btn" onclick="toggleLibraryMode()" title="Switch library mode">
|
||||||
|
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<main id="local-content" class="hidden">
|
||||||
<main>
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title">Local Books Library</div>
|
||||||
|
<button class="btn-secondary" onclick="scanLocalLibrary()">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M21 12a9 9 0 1 1-9-9"/><path d="M21 3v6h-6"/>
|
||||||
|
</svg>
|
||||||
|
<span id="scan-text">Scan Library</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="local-filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<button class="filter-btn active" data-filter="all">All</button>
|
||||||
|
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<button class="filter-btn" data-sort="az">A–Z</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="local-library-grid" id="local-entries-grid">
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<main id="online-content">
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div class="section-title">Continue Reading</div>
|
<div class="section-title">Continue Reading</div>
|
||||||
@@ -87,7 +119,7 @@
|
|||||||
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
<script src="/src/scripts/utils/list-modal-manager.js"></script>
|
||||||
<script src="/src/scripts/utils/continue-watching-manager.js"></script>
|
<script src="/src/scripts/utils/continue-watching-manager.js"></script>
|
||||||
<script src="/src/scripts/books/books.js"></script>
|
<script src="/src/scripts/books/books.js"></script>
|
||||||
|
<script src="/src/scripts/local-library-books.js"></script>
|
||||||
<script src="/src/scripts/updateNotifier.js"></script>
|
<script src="/src/scripts/updateNotifier.js"></script>
|
||||||
<script src="/src/scripts/auth-guard.js"></script>
|
<script src="/src/scripts/auth-guard.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user