caching system + add extension entries to metadata pool

This commit is contained in:
2025-12-02 18:21:41 +01:00
parent af1e1d8098
commit 47169a5f66
18 changed files with 924 additions and 485 deletions

View File

@@ -4,17 +4,17 @@ The official recode repo, its private no one should know about this or have the
# Things to get done:
| Task | Status | Notes |
| -----|------| ------ |
| -----|----------| ------ |
| Book Reader | ✅ | N/A |
| Multi book provider loading | ✅ | N/A |
| Better Code Organization | ✅ | N/A |
| Mobile View | Not Done | N/A |
| Gallery | Not Done | N/A |
| Gallery | | N/A |
| Anime Schedule (Release Calendar for the week/month) | ✅ | N/A |
| My List (Tracking) | Not Done | Persistent data would be in a data.db file in waifuboard directory |
| Marketplace | Not Done | Uses the gitea repo |
| Add to list / library | Not Done | Persistent data would be in data.db file in waifuboard directory|
| Gallery favorites | Not Done | Persistent in data.db like how it was previously |
| Gallery favorites | | Persistent in data.db like how it was previously |
| Change "StreamFlow" to "WaifuBoard" | ✅ | N/A |
| Change the cube icon next to "StreamFlow" to the current ico file | ✅ | Use the ico file from the current waifuboard ver |
| Favicon | ✅ | Use the ico file from the current waifuboard ver |

View File

@@ -77,6 +77,7 @@ const start = async () => {
try {
initDatabase("anilist");
initDatabase("favorites");
initDatabase("cache");
await loadExtensions();
await fastify.listen({ port: 3000, host: '0.0.0.0' });

View File

@@ -12,15 +12,8 @@ export async function getAnime(req: AnimeRequest, reply: FastifyReply) {
if (source === 'anilist') {
anime = await animeService.getAnimeById(id);
} else {
const extensionName = source;
const ext = getExtension(extensionName);
const results = await animeService.searchAnimeInExtension(
ext,
extensionName,
id.replace(/--/g, '\u0000').replace(/-/g, ' ').replace(new RegExp('\u0000', 'g'), '-').trim()
);
anime = results[0] || null;
const ext = getExtension(source);
anime = await animeService.getAnimeInfoExtension(ext, id)
}
return anime;
@@ -35,10 +28,10 @@ export async function getAnimeEpisodes(req: AnimeRequest, reply: FastifyReply) {
const extensionName = req.query.ext || 'anilist';
const ext = getExtension(extensionName);
return await animeService.searchChaptersInExtension(
return await animeService.searchEpisodesInExtension(
ext,
extensionName,
id.replace(/--/g, '\u0000').replace(/-/g, ' ').replace(new RegExp('\u0000', 'g'), '-').trim()
id
);
} catch (err) {
return { error: "Database error" };
@@ -72,14 +65,26 @@ export async function search(req: SearchRequest, reply: FastifyReply) {
return { results: results };
}
const extResults = await animeService.searchAnimeExtensions(query);
return { results: extResults };
} catch (err) {
return { results: [] };
}
}
export async function searchInExtension(req: any, reply: FastifyReply) {
try {
const extensionName = req.params.extension;
const query = req.query.q;
const ext = getExtension(extensionName);
if (!ext) return { results: [] };
const results = await animeService.searchAnimeInExtension(ext, extensionName, query);
return { results };
} catch {
return { results: [] };
}
}
export async function getWatchStream(req: WatchStreamRequest, reply: FastifyReply) {
try {
const { animeId, episode, server, category, ext } = req.query;

View File

@@ -7,6 +7,7 @@ async function animeRoutes(fastify: FastifyInstance) {
fastify.get('/trending', controller.getTrending);
fastify.get('/top-airing', controller.getTopAiring);
fastify.get('/search', controller.search);
fastify.get('/search/:extension', controller.searchInExtension);
fastify.get('/watch/stream', controller.getWatchStream);
}

View File

@@ -1,7 +1,8 @@
import { queryOne, queryAll } from '../shared/database';
import { getAnimeExtensionsMap } from '../shared/extensions';
import {queryOne, queryAll, getCache, setCache, getCachedExtension, cacheExtension, getExtensionTitle } from '../shared/database';
import {Anime, Episode, Extension, StreamData} from '../types';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
export async function getAnimeById(id: string | number): Promise<Anime | { error: string }> {
const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]);
@@ -47,6 +48,38 @@ export async function searchAnimeLocal(query: string): Promise<Anime[]> {
return cleanResults.slice(0, 10);
}
export async function getAnimeInfoExtension(ext: Extension | null, id: string): Promise<Anime | { error: string }> {
if (!ext) return { error: "not found" };
const extName = ext.constructor.name;
const cached = await getCachedExtension(extName, id);
if (cached) {
try {
console.log(`[${extName}] Metadata cache hit for ID: ${id}`);
return JSON.parse(cached.metadata) as Anime;
} catch {
}
}
if ((ext.type === 'anime-board') && ext.getMetadata) {
try {
const match = await ext.getMetadata(id);
if (match) {
await cacheExtension(extName, id, match.title, match);
return match;
}
} catch (e) {
console.error(`Extension getMetadata failed:`, e);
}
}
return { error: "not found" };
}
export async function searchAnimeInExtension(ext: Extension | null, name: string, query: string): Promise<Anime[]> {
if (!ext) return [];
@@ -71,7 +104,7 @@ export async function searchAnimeInExtension(ext: Extension | null, name: string
averageScore: m.rating || m.score || null,
format: 'ANIME',
seasonYear: null,
isExtensionResult: true
isExtensionResult: true,
}));
}
} catch (e) {
@@ -82,11 +115,33 @@ export async function searchAnimeInExtension(ext: Extension | null, name: string
return [];
}
export async function searchChaptersInExtension(ext: Extension | null, name: string, query: string): Promise<Episode[]> {
export async function searchEpisodesInExtension(ext: Extension | null, name: string, query: string): Promise<Episode[]> {
if (!ext) return [];
const cacheKey = `anime:episodes:${name}:${query}`;
const cached = await getCache(cacheKey);
if (cached) {
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
if (!isExpired) {
console.log(`[${name}] Episodes cache hit for: ${query}`);
try {
return JSON.parse(cached.result) as Episode[];
} catch (e) {
console.error(`[${name}] Error parsing cached episodes:`, e);
}
} else {
console.log(`[${name}] Episodes cache expired for: ${query}`);
}
}
if (ext.type === "anime-board" && ext.search && typeof ext.findEpisodes === "function") {
try {
const title = await getExtensionTitle(name, query);
let mediaId: string;
if (title) {
const matches = await ext.search({
query,
@@ -102,15 +157,28 @@ export async function searchChaptersInExtension(ext: Extension | null, name: str
const res = matches[0];
if (!res?.id) return [];
const chapterList = await ext.findEpisodes(res.id);
mediaId = res.id;
} else {
mediaId = query;
}
const chapterList = await ext.findEpisodes(mediaId);
if (!Array.isArray(chapterList)) return [];
return chapterList.map(ep => ({
const result: Episode[] = chapterList.map(ep => ({
id: ep.id,
number: ep.number,
url: ep.url,
title: ep.title
}));
await setCache(cacheKey, result, CACHE_TTL_MS);
return result;
} catch (e) {
console.error(`Extension search failed for ${name}:`, e);
}
@@ -119,18 +187,28 @@ export async function searchChaptersInExtension(ext: Extension | null, name: str
return [];
}
export async function searchAnimeExtensions(query: string): Promise<Anime[]> {
const animeExtensions = getAnimeExtensionsMap();
for (const [name, ext] of animeExtensions) {
const results = await searchAnimeInExtension(ext, name, query);
if (results.length > 0) return results;
}
return [];
}
export async function getStreamData(extension: Extension, animeData: Anime, episode: string, server?: string, category?: string): Promise<StreamData> {
const providerName = extension.constructor.name;
const cacheKey = `anime:stream:${providerName}:${animeData.id}:${episode}:${server || 'default'}:${category || 'sub'}`;
const cached = await getCache(cacheKey);
if (cached) {
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
if (!isExpired) {
console.log(`[${providerName}] Stream data cache hit for episode ${episode}`);
try {
return JSON.parse(cached.result) as StreamData;
} catch (e) {
console.error(`[${providerName}] Error parsing cached stream data:`, e);
}
} else {
console.log(`[${providerName}] Stream data cache expired for episode ${episode}`);
}
}
const searchOptions = {
query: animeData.title.english || animeData.title.romaji,
dub: category === 'dub',
@@ -162,5 +240,6 @@ export async function getStreamData(extension: Extension, animeData: Anime, epis
const serverName = server || "default";
const streamData = await extension.findEpisodeServer(targetEp, serverName);
await setCache(cacheKey, streamData, CACHE_TTL_MS);
return streamData;
}

View File

@@ -1,7 +1,7 @@
import {FastifyReply, FastifyRequest} from 'fastify';
import * as booksService from './books.service';
import {getExtension} from '../shared/extensions';
import { BookRequest, SearchRequest, ChapterRequest } from '../types';
import {BookRequest, ChapterRequest, SearchRequest} from '../types';
export async function getBook(req: BookRequest, reply: FastifyReply) {
try {
@@ -12,11 +12,10 @@ export async function getBook(req: BookRequest, reply: FastifyReply) {
if (source === 'anilist') {
book = await booksService.getBookById(id);
} else {
const extensionName = source;
const ext = getExtension(extensionName);
const ext = getExtension(source);
const results = await booksService.searchBooksInExtension(ext, extensionName, id.replace(/--/g, '\u0000').replace(/-/g, ' ').replace(new RegExp('\u0000', 'g'), '-').trim());
book = results[0] || null;
const result = await booksService.getBookInfoExtension(ext, id);
book = result || null;
}
return book;
@@ -60,8 +59,7 @@ export async function searchBooks(req: SearchRequest, reply: FastifyReply) {
return { results: anilistResults };
}
const extResults = await booksService.searchBooksExtensions(query);
return { results: extResults };
return { results: [] };
} catch (e) {
const error = e as Error;
@@ -70,6 +68,23 @@ export async function searchBooks(req: SearchRequest, reply: FastifyReply) {
}
}
export async function searchBooksInExtension(req: any, reply: FastifyReply) {
try {
const extensionName = req.params.extension;
const query = req.query.q;
const ext = getExtension(extensionName);
if (!ext) return { results: [] };
const results = await booksService.searchBooksInExtension(ext, extensionName, query);
return { results };
} catch (e) {
const error = e as Error;
console.error("Search Error:", error.message);
return { results: [] };
}
}
export async function getChapters(req: BookRequest, reply: FastifyReply) {
try {
const { id } = req.params;

View File

@@ -6,6 +6,7 @@ async function booksRoutes(fastify: FastifyInstance) {
fastify.get('/books/trending', controller.getTrending);
fastify.get('/books/popular', controller.getPopular);
fastify.get('/search/books', controller.searchBooks);
fastify.get('/search/books/:extension', controller.searchBooksInExtension);
fastify.get('/book/:id/chapters', controller.getChapters);
fastify.get('/book/:bookId/:chapter/:provider', controller.getChapterContent);
}

View File

@@ -1,7 +1,9 @@
import { queryOne, queryAll } from '../shared/database';
import { queryOne, queryAll, getCachedExtension, cacheExtension, getCache, setCache, getExtensionTitle } from '../shared/database';
import { getAllExtensions, getBookExtensionsMap } from '../shared/extensions';
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
export async function getBookById(id: string | number): Promise<Book | { error: string }> {
const row = await queryOne("SELECT full_data FROM books WHERE id = ?", [id]);
@@ -108,10 +110,39 @@ export async function searchBooksAniList(query: string): Promise<Book[]> {
return [];
}
export async function getBookInfoExtension(ext: Extension | null, id: string): Promise<Book[]> {
if (!ext) return [];
const extName = ext.constructor.name;
const cached = await getCachedExtension(extName, id);
if (cached) {
try {
return JSON.parse(cached.metadata);
} catch {
}
}
if (ext.type === 'book-board' && ext.getMetadata) {
try {
const info = await ext.getMetadata(id);
if (info) {
await cacheExtension(extName, id, info.title, info);
return info;
}
} catch (e) {
console.error(`Extension getInfo failed:`, e);
}
}
return [];
}
export async function searchBooksInExtension(ext: Extension | null, name: string, query: string): Promise<Book[]> {
if (!ext) return [];
if ((ext.type === 'book-board' || ext.type === 'manga-board') && ext.search) {
if ((ext.type === 'book-board') && ext.search) {
try {
console.log(`[${name}] Searching for book: ${query}`);
const matches = await ext.search({
@@ -130,7 +161,7 @@ export async function searchBooksInExtension(ext: Extension | null, name: string
title: { romaji: m.title, english: m.title, native: null },
coverImage: { large: m.image || '' },
averageScore: m.rating || m.score || null,
format: 'MANGA',
format: m.format,
seasonYear: null,
isExtensionResult: true
}));
@@ -143,17 +174,6 @@ export async function searchBooksInExtension(ext: Extension | null, name: string
return [];
}
export async function searchBooksExtensions(query: string): Promise<Book[]> {
const bookExtensions = getBookExtensionsMap();
for (const [name, ext] of bookExtensions) {
const results = await searchBooksInExtension(ext, name, query);
if (results.length > 0) return results;
}
return [];
}
async function fetchBookMetadata(id: string): Promise<Book | null> {
try {
const query = `query ($id: Int) {
@@ -177,44 +197,68 @@ async function fetchBookMetadata(id: string): Promise<Book | null> {
}
}
async function searchChaptersInExtension(ext: Extension, name: string, searchTitle: string, bookData: Book | null): Promise<ChapterWithProvider[]> {
async function searchChaptersInExtension(ext: Extension, name: string, searchTitle: string, search: boolean): Promise<ChapterWithProvider[]> {
const cacheKey = `chapters:${name}:${searchTitle}`;
const cached = await getCache(cacheKey);
if (cached) {
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
if (!isExpired) {
console.log(`[${name}] Chapters cache hit for: ${searchTitle}`);
try {
return JSON.parse(cached.result) as ChapterWithProvider[];
} catch (e) {
console.error(`[${name}] Error parsing cached chapters:`, e);
}
} else {
console.log(`[${name}] Chapters cache expired for: ${searchTitle}`);
}
}
try {
console.log(`[${name}] Searching chapters for: ${searchTitle}`);
let mediaId: string;
if (search) {
const matches = await ext.search!({
query: searchTitle,
media: bookData ? {
romajiTitle: bookData.title.romaji,
englishTitle: bookData.title.english || "",
startDate: bookData.startDate || { year: 0, month: 0, day: 0 }
} : {
media: {
romajiTitle: searchTitle,
englishTitle: searchTitle,
startDate: { year: 0, month: 0, day: 0 }
}
});
if (!matches?.length) {
console.log(`[${name}] No matches found for book.`);
return [];
const best = matches?.[0];
if (!best) { return [] }
mediaId = best.id;
} else {
const match = await ext.getMetadata(searchTitle);
mediaId = match.id;
}
const best = matches[0];
const chaps = await ext.findChapters!(best.id);
const chaps = await ext.findChapters!(mediaId);
if (!chaps?.length) {
return [];
}
console.log(`[${name}] Found ${chaps.length} chapters.`);
return chaps.map((ch) => ({
const result: ChapterWithProvider[] = chaps.map((ch) => ({
id: ch.id,
number: parseFloat(ch.number.toString()),
title: ch.title,
date: ch.releaseDate,
provider: name
}));
await setCache(cacheKey, result, CACHE_TTL_MS);
return result;
} catch (e) {
const error = e as Error;
console.error(`Failed to fetch chapters from ${name}:`, error.message);
@@ -224,39 +268,40 @@ async function searchChaptersInExtension(ext: Extension, name: string, searchTit
export async function getChaptersForBook(id: string): Promise<{ chapters: ChapterWithProvider[] }> {
let bookData: Book | null = null;
let searchTitle: string | null = null;
let searchTitle: string = "";
if (isNaN(Number(id))) {
searchTitle = id.replace(/--/g, '\u0000').replace(/-/g, ' ').replace(new RegExp('\u0000', 'g'), '-').trim();
} else {
const result = await getBookById(id);
if (!('error' in result)) {
bookData = result;
}
if (!bookData) {
if (!isNaN(Number(id))) {
bookData = await fetchBookMetadata(id);
}
if (!bookData) {
return { chapters: [] };
}
const titles = [bookData.title.english, bookData.title.romaji].filter(Boolean) as string[];
searchTitle = titles[0];
}
const allChapters: ChapterWithProvider[] = [];
const bookExtensions = getBookExtensionsMap();
const searchPromises = Array.from(bookExtensions.entries())
.filter(([_, ext]) => ext.search && ext.findChapters)
.map(async ([name, ext]) => {
const chapters = await searchChaptersInExtension(ext, name, searchTitle!, bookData);
allChapters.push(...chapters);
});
let extension;
if (!searchTitle) {
for (const [name, ext] of bookExtensions) {
const title = await getExtensionTitle(name, id)
if (title){
searchTitle = title;
extension = name;
}
}
}
await Promise.all(searchPromises);
const allChapters: any[] = [];
for (const [name, ext] of bookExtensions) {
if (name == extension) {
const chapters = await searchChaptersInExtension(ext, name, id, false);
allChapters.push(...chapters);
} else {
const chapters = await searchChaptersInExtension(ext, name, searchTitle, true);
allChapters.push(...chapters);
}
}
return {
chapters: allChapters.sort((a, b) => Number(a.number) - Number(b.number))
@@ -271,6 +316,25 @@ export async function getChapterContent(bookId: string, chapterIndex: string, pr
throw new Error("Provider not found");
}
const contentCacheKey = `content:${providerName}:${bookId}:${chapterIndex}`;
const cachedContent = await getCache(contentCacheKey);
if (cachedContent) {
const isExpired = Date.now() - cachedContent.created_at > CACHE_TTL_MS;
if (!isExpired) {
console.log(`[${providerName}] Content cache hit for Book ID ${bookId}, Index ${chapterIndex}`);
try {
return JSON.parse(cachedContent.result) as ChapterContent;
} catch (e) {
console.error(`[${providerName}] Error parsing cached content:`, e);
}
} else {
console.log(`[${providerName}] Content cache expired for Book ID ${bookId}, Index ${chapterIndex}`);
}
}
const chapterList = await getChaptersForBook(bookId);
if (!chapterList?.chapters || chapterList.chapters.length === 0) {
@@ -299,9 +363,11 @@ export async function getChapterContent(bookId: string, chapterIndex: string, pr
throw new Error("Extension doesn't support findChapterPages");
}
let contentResult: ChapterContent;
if (ext.mediaType === "manga") {
const pages = await ext.findChapterPages(chapterId);
return {
contentResult = {
type: "manga",
chapterId,
title: chapterTitle,
@@ -309,11 +375,9 @@ export async function getChapterContent(bookId: string, chapterIndex: string, pr
provider: providerName,
pages
};
}
if (ext.mediaType === "ln") {
} else if (ext.mediaType === "ln") {
const content = await ext.findChapterPages(chapterId);
return {
contentResult = {
type: "ln",
chapterId,
title: chapterTitle,
@@ -321,9 +385,14 @@ export async function getChapterContent(bookId: string, chapterIndex: string, pr
provider: providerName,
content
};
} else {
throw new Error("Unknown mediaType");
}
throw new Error("Unknown mediaType");
await setCache(contentCacheKey, contentResult, CACHE_TTL_MS);
return contentResult;
} catch (err) {
const error = err as Error;
console.error(`[Chapter] Error loading from ${providerName}:`, error.message);

View File

@@ -36,21 +36,39 @@ async function loadAnime() {
return;
}
const title = data.title?.english || data.title?.romaji || "Unknown Title";
const title = data.title?.english || data.title?.romaji || data.title || "Unknown Title";
document.title = `${title} | WaifuBoard`;
document.getElementById('title').innerText = title;
if (data.coverImage?.extraLarge) {
document.getElementById('poster').src = data.coverImage.extraLarge;
let posterUrl = '';
if (extensionName) {
posterUrl = data.image || '';
} else {
posterUrl = data.coverImage?.extraLarge || '';
}
const rawDesc = data.description || "No description available.";
if (posterUrl) {
document.getElementById('poster').src = posterUrl;
}
const rawDesc = data.description || data.summary || "No description available.";
handleDescription(rawDesc);
document.getElementById('score').innerText = (data.averageScore || '?') + '% Score';
document.getElementById('year').innerText = data.seasonYear || data.startDate?.year || '????';
document.getElementById('genres').innerText = data.genres?.length > 0 ? data.genres.slice(0, 3).join(' • ') : '';
const score = extensionName ? (data.score ? data.score * 10 : '?') : data.averageScore;
document.getElementById('score').innerText = (score || '?') + '% Score';
document.getElementById('year').innerText =
extensionName ? (data.year || '????') : (data.seasonYear || data.startDate?.year || '????');
document.getElementById('genres').innerText =
data.genres?.length > 0 ? data.genres.slice(0, 3).join(' • ') : '';
document.getElementById('format').innerText = data.format || 'TV';
document.getElementById('status').innerText = data.status || 'Unknown';
const extensionPill = document.getElementById('extension-pill');
if (extensionName && extensionPill) {
extensionPill.textContent = `${extensionName.charAt(0).toUpperCase() + extensionName.slice(1).toLowerCase()}`;
@@ -60,6 +78,9 @@ async function loadAnime() {
}
let seasonText = '';
if (extensionName) {
seasonText = data.season || 'Unknown';
} else {
if (data.season && data.seasonYear) {
seasonText = `${data.season} ${data.seasonYear}`;
} else if (data.startDate?.year) {
@@ -68,37 +89,42 @@ async function loadAnime() {
const estimatedSeason = months[month] || '';
seasonText = `${estimatedSeason} ${data.startDate.year}`.trim();
}
}
document.getElementById('season').innerText = seasonText || 'Unknown';
let studioName = 'Unknown Studio';
if (data.studios?.nodes?.length > 0) {
studioName = data.studios.nodes[0].name;
} else if (data.studios?.edges?.length > 0) {
studioName = data.studios.edges[0]?.node?.name || 'Unknown Studio';
}
document.getElementById('studio').innerText = studioName;
const studio = extensionName
? data.studio || "Unknown"
: (data.studios?.nodes?.[0]?.name ||
data.studios?.edges?.[0]?.node?.name ||
'Unknown Studio');
document.getElementById('studio').innerText = studio;
const charContainer = document.getElementById('char-list');
charContainer.innerHTML = '';
let characters = [];
if (extensionName) {
characters = data.characters || [];
} else {
if (data.characters?.nodes?.length > 0) {
characters = data.characters.nodes.slice(0, 5);
}
else if (data.characters?.edges?.length > 0) {
} else if (data.characters?.edges?.length > 0) {
characters = data.characters.edges
.filter(edge => edge?.node?.name?.full)
.slice(0, 5)
.map(edge => edge.node);
}
}
if (characters.length > 0) {
characters.forEach(char => {
if (char?.name?.full) {
characters.slice(0, 5).forEach(char => {
const name = char?.name?.full || char?.name;
if (name) {
charContainer.innerHTML += `
<div class="character-item">
<div class="char-dot"></div> ${char.name.full}
<div class="char-dot"></div> ${name}
</div>`;
}
});
@@ -120,32 +146,27 @@ async function loadAnime() {
width: '100%',
videoId: data.trailer.id,
playerVars: {
'autoplay': 1, 'controls': 0, 'mute': 1,
'loop': 1, 'playlist': data.trailer.id,
'showinfo': 0, 'modestbranding': 1, 'disablekb': 1
autoplay: 1, controls: 0, mute: 1,
loop: 1, playlist: data.trailer.id,
showinfo: 0, modestbranding: 1, disablekb: 1
},
events: { 'onReady': (e) => e.target.playVideo() }
events: { onReady: (e) => e.target.playVideo() }
});
};
} else {
const banner = data.bannerImage || data.coverImage?.extraLarge || '';
if (banner) {
document.querySelector('.video-background').innerHTML = `<img src="${banner}" style="width:100%; height:100%; object-fit:cover;">`;
}
}
const banner = extensionName
? (data.image || '')
: (data.bannerImage || data.coverImage?.extraLarge || '');
let extensionEpisodes = [];
if (banner) {
document.querySelector('.video-background').innerHTML =
`<img src="${banner}" style="width:100%; height:100%; object-fit:cover;">`;
}
}
if (extensionName) {
extensionEpisodes = await loadExtensionEpisodes(animeId, extensionName);
if (extensionEpisodes.length > 0) {
totalEpisodes = extensionEpisodes.length;
totalEpisodes = data.episodes || 1;
} else {
totalEpisodes = 1;
}
} else {
// MODO NORMAL (AniList)
if (data.nextAiringEpisode?.episode) {
totalEpisodes = data.nextAiringEpisode.episode - 1;
} else if (data.episodes) {
@@ -166,27 +187,6 @@ async function loadAnime() {
}
}
async function loadExtensionEpisodes(animeId, extName) {
try {
const url = `/api/anime/${animeId}/episodes?ext=${extName}`;
const res = await fetch(url);
const data = await res.json();
if (!Array.isArray(data)) return [];
return data.map(ep => ({
id: ep.id,
number: ep.number,
title: ep.title || `Episode ${ep.number}`,
url: ep.url
}));
} catch (err) {
console.error("Failed to fetch extension episodes:", err);
return [];
}
}
function handleDescription(text) {
const tmp = document.createElement("DIV");
tmp.innerHTML = text;

View File

@@ -1,6 +1,7 @@
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
let searchTimeout;
let availableExtensions = [];
searchInput.addEventListener('input', (e) => {
const query = e.target.value;
@@ -12,6 +13,7 @@ searchInput.addEventListener('input', (e) => {
return;
}
searchTimeout = setTimeout(() => {
fetchSearh(query);
}, 300);
});
@@ -25,20 +27,48 @@ document.addEventListener('click', (e) => {
async function fetchSearh(query) {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query.slice(0, 30))}`);
const data = await res.json();
renderSearchResults(data.results);
} catch (err) { console.error("Search Error:", err); }
let apiUrl = `/api/search?q=${encodeURIComponent(query.slice(0, 30))}`;
let extensionName = null;
let finalQuery = query;
const parts = query.split(':');
if (parts.length >= 2) {
const potentialExtension = parts[0].trim().toLowerCase();
const foundExtension = availableExtensions.find(ext => ext.toLowerCase() === potentialExtension);
if (foundExtension) {
extensionName = foundExtension;
finalQuery = parts.slice(1).join(':').trim();
if (finalQuery.length === 0) {
renderSearchResults([]);
return;
}
function createSlug(text) {
if (!text) return '';
return text
.toLowerCase()
.trim()
.replace(/-/g, '--')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9\-]/g, '');
apiUrl = `/api/search/${extensionName}?q=${encodeURIComponent(finalQuery.slice(0, 30))}`;
}
}
const res = await fetch(apiUrl);
const data = await res.json();
const resultsWithExtension = (data.results || []).map(anime => {
if (extensionName) {
return {
...anime,
isExtensionResult: true,
extensionName: extensionName
};
}
return anime;
});
renderSearchResults(resultsWithExtension);
} catch (err) {
console.error("Search Error:", err);
}
}
function renderSearchResults(results) {
@@ -54,20 +84,12 @@ function renderSearchResults(results) {
const format = anime.format || 'TV';
let href;
if (anime.isExtensionResult) {
const titleSlug = createSlug(title);
console.log(title);
href = `/anime/${anime.extensionName}/${titleSlug}`;
href = `/anime/${anime.extensionName}/${anime.id}`;
} else {
href = `/anime/${anime.id}`;
}
const extName = anime.extensionName?.charAt(0).toUpperCase() + anime.extensionName?.slice(1);
const extPill = anime.isExtensionResult
? `<span>• ${extName}</span>`
: '';
const item = document.createElement('a');
item.className = 'search-item';
item.href = href;
@@ -79,7 +101,6 @@ function renderSearchResults(results) {
<span class="rating-pill">${rating}</span>
<span>• ${year}</span>
<span>• ${format}</span>
${extPill}
</div>
</div>
`;
@@ -118,6 +139,14 @@ function onYouTubeIframeAPIReady() {
async function fetchContent(isUpdate = false) {
try {
const resExt = await fetch('/api/extensions/anime');
const dataExt = await resExt.json();
if (dataExt.extensions) {
availableExtensions = dataExt.extensions;
console.log("Extensiones de anime disponibles cargadas:", availableExtensions);
}
const trendingRes = await fetch('/api/trending');
const trendingData = await trendingRes.json();
@@ -220,6 +249,7 @@ function renderList(id, list) {
const el = document.createElement('div');
el.className = 'card';
el.dataset.id = anime.id;
el.onclick = () => window.location.href = `/anime/${anime.id}`;
el.innerHTML = `
<div class="card-img-wrap"><img src="${cover}" loading="lazy"></div>

View File

@@ -23,115 +23,158 @@ document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`
async function loadMetadata() {
try {
const extQuery = extName ? `?ext=${extName}` : "";
const res = await fetch(`/api/anime/${animeId}${extQuery}`);
const data = await res.json();
if (!data.error) {
const romajiTitle = data.title.romaji || data.title.english || 'Anime Title';
if (data.error) {
console.error("Error from API:", data.error);
return;
}
document.getElementById('anime-title-details').innerText = romajiTitle;
document.getElementById('anime-title-details2').innerText = romajiTitle;
const isAnilistFormat = data.title && (data.title.romaji || data.title.english);
document.title = `Watching ${romajiTitle} - Ep ${currentEpisode}`;
let title = '';
let description = '';
let coverImage = '';
let averageScore = '';
let format = '';
let seasonYear = '';
let season = '';
let episodesCount = 0;
let characters = [];
if (isAnilistFormat) {
title = data.title.romaji || data.title.english || data.title.native || 'Anime Title';
description = data.description || 'No description available.';
coverImage = data.coverImage?.large || data.coverImage?.medium || '';
averageScore = data.averageScore ? `${data.averageScore}%` : '--';
format = data.format || '--';
season = data.season ? data.season.charAt(0) + data.season.slice(1).toLowerCase() : '';
seasonYear = data.seasonYear || '';
episodesCount = data.episodes || 0;
characters = data.characters?.edges || [];
} else {
title = data.title || 'Anime Title';
description = data.summary || 'No description available.';
coverImage = data.image || '';
averageScore = data.score ? `${Math.round(data.score * 10)}%` : '--';
format = '--';
season = data.season || '';
seasonYear = data.year || '';
episodesCount = data.episodes || 0;
characters = data.characters || [];
}
document.getElementById('anime-title-details').innerText = title;
document.getElementById('anime-title-details2').innerText = title;
document.title = `Watching ${title} - Ep ${currentEpisode}`;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.description || 'No description available.';
document.getElementById('detail-description').innerText =
tempDiv.textContent || tempDiv.innerText;
tempDiv.innerHTML = description;
document.getElementById('detail-description').innerText = tempDiv.textContent || tempDiv.innerText || 'No description available.';
document.getElementById('detail-format').innerText = data.format || '--';
document.getElementById('detail-score').innerText =
data.averageScore ? `${data.averageScore}%` : '--';
document.getElementById('detail-format').innerText = format;
document.getElementById('detail-score').innerText = averageScore;
document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--');
document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg';
const season = data.season
? data.season.charAt(0) + data.season.slice(1).toLowerCase()
: '';
document.getElementById('detail-season').innerText =
data.seasonYear ? `${season} ${data.seasonYear}` : '--';
document.getElementById('detail-cover-image').src =
data.coverImage.large || data.coverImage.medium || '';
if (data.characters && data.characters.edges && data.characters.edges.length > 0) {
populateCharacters(data.characters.edges);
if (Array.isArray(characters) && characters.length > 0) {
populateCharacters(characters, isAnilistFormat);
}
if (!extName) {
totalEpisodes = data.episodes || 0;
totalEpisodes = episodesCount;
if (totalEpisodes > 0) {
const simpleEpisodes = [];
for (let i = 1; i <= totalEpisodes; i++) {
simpleEpisodes.push({
number: i,
title: null,
thumbnail: null,
isDub: false
});
}
populateEpisodeCarousel(simpleEpisodes);
}
} else {
try {
const res2 = await fetch(`/api/anime/${animeId}/episodes${extQuery}`);
const data2 = await res2.json();
totalEpisodes = Array.isArray(data2) ? data2.length : 0;
if (Array.isArray(data2) && data2.length > 0) {
populateEpisodeCarousel(data2);
await loadExtensionEpisodes();
}
if (currentEpisode >= totalEpisodes && totalEpisodes > 0) {
document.getElementById('next-btn').disabled = true;
}
} catch (error) {
console.error('Error loading metadata:', error);
}
}
async function loadExtensionEpisodes() {
try {
const extQuery = extName ? `?ext=${extName}` : "";
const res = await fetch(`/api/anime/${animeId}/episodes${extQuery}`);
const data = await res.json();
totalEpisodes = Array.isArray(data) ? data.length : 0;
if (Array.isArray(data) && data.length > 0) {
populateEpisodeCarousel(data);
} else {
const fallback = [];
for (let i = 1; i <= totalEpisodes; i++) {
fallback.push({ number: i, title: null, thumbnail: null });
}
populateEpisodeCarousel(fallback);
}
} catch (e) {
console.error("Error cargando episodios por extensión:", e);
totalEpisodes = 0;
}
}
if (currentEpisode >= totalEpisodes && totalEpisodes > 0) {
document.getElementById('next-btn').disabled = true;
}
}
} catch (error) {
console.error('Error loading metadata:', error);
}
}
function populateCharacters(characterEdges) {
function populateCharacters(characters, isAnilistFormat) {
const list = document.getElementById('characters-list');
list.classList.remove('characters-list');
list.classList.add('characters-carousel');
list.innerHTML = '';
characterEdges.forEach(edge => {
const character = edge.node;
const voiceActor = edge.voiceActors ? edge.voiceActors.find(va => va.language === 'Japanese' || va.language === 'English') : null;
characters.forEach(item => {
let character, voiceActor;
if (isAnilistFormat) {
character = item.node;
voiceActor = item.voiceActors?.find(va => va.language === 'Japanese' || va.language === 'English');
} else {
character = item;
voiceActor = null;
}
if (!character) return;
if (character) {
const card = document.createElement('div');
card.classList.add('character-card');
const img = document.createElement('img');
img.classList.add('character-card-img');
img.src = character.image.large || character.image.medium || '';
img.alt = character.name.full || 'Character';
const vaName = voiceActor ? (voiceActor.name.full || voiceActor.name.userPreferred) : null;
const characterName = character.name.full || character.name.userPreferred || '--';
img.src = character.image?.large || character.image?.medium || character.image || '';
img.alt = character.name?.full || 'Character';
const details = document.createElement('div');
details.classList.add('character-details');
const name = document.createElement('p');
name.classList.add('character-name');
name.innerText = characterName;
name.innerText = character.name?.full || character.name || '--';
const actor = document.createElement('p');
actor.classList.add('actor-name');
if (vaName) {
actor.innerText = `${vaName} (${voiceActor.language})`;
if (voiceActor?.name?.full) {
actor.innerText = `${voiceActor.name.full} (${voiceActor.language})`;
} else {
actor.innerText = 'Voice Actor: N/A';
}
@@ -141,20 +184,18 @@ function populateCharacters(characterEdges) {
card.appendChild(img);
card.appendChild(details);
list.appendChild(card);
}
});
}
function populateEpisodeCarousel(episodesData) {
const carousel = document.getElementById('episode-carousel');
carousel.innerHTML = '';
episodesData.forEach((ep, index) => {
const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1);
if (!epNumber) return;
const extParam = extName ? `?${extName}` : "";
const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== '';
const link = document.createElement('a');
@@ -162,19 +203,13 @@ function populateEpisodeCarousel(episodesData) {
link.classList.add('carousel-item');
link.dataset.episode = epNumber;
if (!hasThumbnail) {
link.classList.add('no-thumbnail');
}
if (parseInt(epNumber) === currentEpisode) {
link.classList.add('active-ep-carousel');
}
if (!hasThumbnail) link.classList.add('no-thumbnail');
if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel');
const imgContainer = document.createElement('div');
imgContainer.classList.add('carousel-item-img-container');
if (hasThumbnail) {
const img = document.createElement('img');
img.classList.add('carousel-item-img');
img.src = ep.thumbnail;
@@ -188,11 +223,9 @@ function populateEpisodeCarousel(episodesData) {
info.classList.add('carousel-item-info');
const title = document.createElement('p');
title.innerText = `Ep ${epNumber}: ${ep.title || 'Untitled'}`;
info.appendChild(title);
link.appendChild(info);
carousel.appendChild(link);
});
@@ -282,14 +315,8 @@ function setAudioMode(mode) {
const dubOpt = document.getElementById('opt-dub');
toggle.setAttribute('data-state', mode);
if (mode === 'sub') {
subOpt.classList.add('active');
dubOpt.classList.remove('active');
} else {
subOpt.classList.remove('active');
dubOpt.classList.add('active');
}
subOpt.classList.toggle('active', mode === 'sub');
dubOpt.classList.toggle('active', mode === 'dub');
}
async function loadStream() {
@@ -323,7 +350,7 @@ async function loadStream() {
if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`;
if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
playVideo(proxyUrl, data.videoSources[0].subtitles);
playVideo(proxyUrl, data.videoSources[0].subtitles || data.subtitles);
document.getElementById('loading-overlay').style.display = 'none';
} catch (error) {
setLoading("Stream error. Check console.");
@@ -331,17 +358,12 @@ async function loadStream() {
}
}
function playVideo(url, subtitles) {
function playVideo(url, subtitles = []) {
const video = document.getElementById('player');
if (Hls.isSupported()) {
if (hlsInstance) hlsInstance.destroy();
hlsInstance = new Hls({
xhrSetup: (xhr, url) => {
xhr.withCredentials = false;
}
});
hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false });
hlsInstance.loadSource(url);
hlsInstance.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
@@ -350,52 +372,28 @@ function playVideo(url, subtitles) {
if (plyrInstance) plyrInstance.destroy();
while (video.firstChild) {
video.removeChild(video.firstChild);
while (video.textTracks.length > 0) {
video.removeChild(video.textTracks[0]);
}
if (subtitles && subtitles.length > 0) {
subtitles.forEach(sub => {
if (!sub.url) return;
const track = document.createElement('track');
track.kind = 'captions';
track.label = sub.language;
track.srclang = sub.language.slice(0, 2).toLowerCase();
track.label = sub.language || 'Unknown';
track.srclang = (sub.language || '').slice(0, 2).toLowerCase();
track.src = sub.url;
if (sub.default || sub.language.toLowerCase().includes('english')) {
track.default = true;
}
if (sub.default || sub.language?.toLowerCase().includes('english')) track.default = true;
video.appendChild(track);
});
}
plyrInstance = new Plyr(video, {
captions: {
active: true,
update: true,
language: 'en'
},
controls: [
'play-large',
'play',
'progress',
'current-time',
'duration',
'mute',
'volume',
'captions',
'settings',
'pip',
'airplay',
'fullscreen'
],
captions: { active: true, update: true, language: 'en' },
controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'],
settings: ['captions', 'quality', 'speed']
});
video.play().catch(error => {
console.log("Autoplay blocked:", error);
});
video.play().catch(() => console.log("Autoplay blocked"));
}
function setLoading(message) {
@@ -414,7 +412,9 @@ document.getElementById('prev-btn').onclick = () => {
};
document.getElementById('next-btn').onclick = () => {
if (currentEpisode < totalEpisodes || totalEpisodes === 0) {
window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
}
};
if (currentEpisode <= 1) {
@@ -423,3 +423,4 @@ if (currentEpisode <= 1) {
loadMetadata();
loadExtensions();

View File

@@ -4,35 +4,75 @@ let filteredChapters = [];
let currentPage = 1;
const itemsPerPage = 12;
let extensionName = null;
let bookSlug = null;
async function init() {
try {
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
let bookId;
let currentBookId;
if (parts.length === 3) {
extensionName = parts[1];
bookId = parts[2];
bookSlug = parts[2];
currentBookId = bookSlug;
} else {
bookId = parts[1];
currentBookId = parts[1];
}
const idForFetch = currentBookId;
const fetchUrl = extensionName
? `/api/book/${bookId.slice(0,40)}?ext=${extensionName}`
: `/api/book/${bookId}`;
? `/api/book/${idForFetch.slice(0,40)}?ext=${extensionName}`
: `/api/book/${idForFetch}`;
const res = await fetch(fetchUrl);
const data = await res.json();
console.log(data);
if (data.error) {
if (data.error || !data) {
const titleEl = document.getElementById('title');
if (titleEl) titleEl.innerText = "Book Not Found";
return;
}
const title = data.title.english || data.title.romaji;
let title, description, score, year, status, format, chapters, poster, banner, genres;
if (extensionName) {
title = data.title || data.name || "Unknown";
description = data.summary || "No description available.";
score = data.score ? Math.round(data.score) : '?';
year = data.published || '????';
status = data.status || 'Unknown';
format = data.format || 'LN';
chapters = data.chapters || '?';
poster = data.image || '';
banner = poster;
genres = Array.isArray(data.genres) ? data.genres.slice(0, 3).join(' • ') : '';
} else {
title = data.title.english || data.title.romaji || "Unknown";
description = data.description || "No description available.";
score = data.averageScore || '?';
year = (data.startDate && data.startDate.year) ? data.startDate.year : '????';
status = data.status || 'Unknown';
format = data.format || 'MANGA';
chapters = data.chapters || '?';
poster = data.coverImage.extraLarge || data.coverImage.large || '';
banner = data.bannerImage || poster;
genres = data.genres ? data.genres.slice(0, 3).join(' • ') : '';
}
document.title = `${title} | WaifuBoard Books`;
const titleEl = document.getElementById('title');
@@ -47,62 +87,52 @@ async function init() {
}
const descEl = document.getElementById('description');
if (descEl) descEl.innerHTML = data.description || "No description available.";
if (descEl) descEl.innerHTML = description;
const scoreEl = document.getElementById('score');
if (scoreEl) scoreEl.innerText = (data.averageScore || '?') + '% Score';
if (scoreEl) scoreEl.innerText = score + (extensionName ? '' : '% Score');
const pubEl = document.getElementById('published-date');
if (pubEl) {
if (data.startDate && data.startDate.year) {
const y = data.startDate.year;
const m = data.startDate.month ? `-${data.startDate.month.toString().padStart(2, '0')}` : '';
const d = data.startDate.day ? `-${data.startDate.day.toString().padStart(2, '0')}` : '';
pubEl.innerText = `${y}${m}${d}`;
} else {
pubEl.innerText = '????';
}
}
if (pubEl) pubEl.innerText = year;
const statusEl = document.getElementById('status');
if (statusEl) statusEl.innerText = data.status || 'Unknown';
if (statusEl) statusEl.innerText = status;
const formatEl = document.getElementById('format');
if (formatEl) formatEl.innerText = data.format || 'MANGA';
if (formatEl) formatEl.innerText = format;
const chaptersEl = document.getElementById('chapters');
if (chaptersEl) chaptersEl.innerText = data.chapters || '?';
if (chaptersEl) chaptersEl.innerText = chapters;
const genresEl = document.getElementById('genres');
if(genresEl && data.genres) {
genresEl.innerText = data.genres.slice(0, 3).join(' • ');
if(genresEl) {
genresEl.innerText = genres;
}
const img = data.coverImage.extraLarge || data.coverImage.large;
const posterEl = document.getElementById('poster');
if (posterEl) posterEl.src = img;
if (posterEl) posterEl.src = poster;
const heroBgEl = document.getElementById('hero-bg');
if (heroBgEl) heroBgEl.src = data.bannerImage || img;
if (heroBgEl) heroBgEl.src = banner;
loadChapters();
loadChapters(idForFetch);
} catch (err) {
console.error("Metadata Error:", err);
}
}
async function loadChapters() {
async function loadChapters(idForFetch) {
const tbody = document.getElementById('chapters-body');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">Searching extensions for chapters...</td></tr>';
try {
const fetchUrl = extensionName
? `/api/book/${bookId.slice(0, 40)}/chapters`
: `/api/book/${bookId}/chapters`;
? `/api/book/${idForFetch.slice(0, 40)}/chapters`
: `/api/book/${idForFetch}/chapters`;
const res = await fetch(fetchUrl);
const data = await res.json();
@@ -124,10 +154,11 @@ async function loadChapters() {
const readBtn = document.getElementById('read-start-btn');
if (readBtn && filteredChapters.length > 0) {
readBtn.onclick = () => openReader(filteredChapters[0].id);
readBtn.onclick = () => openReader(idForFetch, filteredChapters[0].id, filteredChapters[0].provider);
}
renderTable();
renderTable(idForFetch);
} catch (err) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; color: #ef4444;">Error loading chapters.</td></tr>';
@@ -153,6 +184,19 @@ function populateProviderFilter() {
select.appendChild(opt);
});
if (extensionName) {
const extensionProvider = providers.find(p => p.toLowerCase() === extensionName.toLowerCase());
if (extensionProvider) {
select.value = extensionProvider;
filteredChapters = allChapters.filter(ch => ch.provider === extensionProvider);
}
}
select.onchange = (e) => {
const selected = e.target.value;
if (selected === 'all') {
@@ -161,12 +205,13 @@ function populateProviderFilter() {
filteredChapters = allChapters.filter(ch => ch.provider === selected);
}
currentPage = 1;
renderTable();
const idForFetch = extensionName ? bookSlug : bookId;
renderTable(idForFetch);
};
}
}
function renderTable() {
function renderTable(idForFetch) {
const tbody = document.getElementById('chapters-body');
if (!tbody) return;
@@ -223,8 +268,10 @@ function updatePagination() {
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage >= totalPages;
prevBtn.onclick = () => { currentPage--; renderTable(); };
nextBtn.onclick = () => { currentPage++; renderTable(); };
const idForFetch = extensionName ? bookSlug : bookId;
prevBtn.onclick = () => { currentPage--; renderTable(idForFetch); };
nextBtn.onclick = () => { currentPage++; renderTable(idForFetch); };
}
function openReader(bookId, chapterId, provider) {

View File

@@ -1,6 +1,7 @@
let trendingBooks = [];
let currentHeroIndex = 0;
let heroInterval;
let availableExtensions = [];
window.addEventListener('scroll', () => {
const nav = document.getElementById('navbar');
@@ -37,25 +38,51 @@ document.addEventListener('click', (e) => {
async function fetchBookSearch(query) {
try {
const res = await fetch(`/api/search/books?q=${encodeURIComponent(query)}`);
let apiUrl = `/api/search/books?q=${encodeURIComponent(query)}`;
let extensionName = null;
let finalQuery = query;
const parts = query.split(':');
if (parts.length >= 2) {
const potentialExtension = parts[0].trim().toLowerCase();
const foundExtension = availableExtensions.find(ext => ext.toLowerCase() === potentialExtension);
if (foundExtension) {
extensionName = foundExtension;
finalQuery = parts.slice(1).join(':').trim();
if (finalQuery.length === 0) {
renderSearchResults([]);
return;
}
apiUrl = `/api/search/books/${extensionName}?q=${encodeURIComponent(finalQuery)}`;
}
}
const res = await fetch(apiUrl);
const data = await res.json();
renderSearchResults(data.results || []);
const resultsWithExtension = data.results.map(book => {
if (extensionName) {
return { ...book,
isExtensionResult: true,
extensionName: extensionName
};
}
return book;
});
renderSearchResults(resultsWithExtension || []);
} catch (err) {
console.error("Search Error:", err);
renderSearchResults([]);
}
}
function createSlug(text) {
if (!text) return '';
return text
.toLowerCase()
.trim()
.replace(/-/g, '--')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9\-]/g, '');
}
function renderSearchResults(results) {
searchResults.innerHTML = '';
@@ -65,25 +92,18 @@ function renderSearchResults(results) {
results.forEach(book => {
const title = book.title.english || book.title.romaji || "Unknown";
const img = (book.coverImage && (book.coverImage.medium || book.coverImage.large)) || '';
const rating = book.averageScore ? `${book.averageScore}%` : 'N/A';
const rating = Number.isInteger(book.averageScore) ? `${book.averageScore}%` : book.averageScore || 'N/A';
const year = book.seasonYear || (book.startDate ? book.startDate.year : '') || '????';
const format = book.format || 'MANGA';
let href;
if (book.isExtensionResult) {
const titleSlug = createSlug(title);
href = `/book/${book.extensionName}/${titleSlug}`;
} else {
href = `/book/${book.id}`;
}
const extName = book.extensionName.charAt(0).toUpperCase() + book.extensionName.slice(1);
const extPill = book.isExtensionResult
? `<span>${extName}</span>`
: '';
const item = document.createElement('a');
item.className = 'search-item';
let href;
if (book.isExtensionResult) {
href = `/book/${book.extensionName}/${book.id}`;
} else {
href = `/book/${book.id}`;
}
item.href = href;
item.innerHTML = `
@@ -94,7 +114,6 @@ function renderSearchResults(results) {
<span class="rating-pill">${rating}</span>
<span>• ${year}</span>
<span>• ${format}</span>
${extPill}
</div>
</div>
`;
@@ -117,6 +136,14 @@ function scrollCarousel(id, direction) {
async function init() {
try {
const resExt = await fetch('/api/extensions/book');
const dataExt = await resExt.json();
if (dataExt.extensions) {
availableExtensions = dataExt.extensions;
console.log("Extensiones disponibles cargadas:", availableExtensions);
}
const res = await fetch('/api/books/trending');
const data = await res.json();
@@ -167,6 +194,7 @@ function updateHeroUI(book) {
const readBtn = document.getElementById('read-btn');
if (readBtn) {
readBtn.onclick = () => window.location.href = `/book/${book.id}`;
}
}
@@ -183,6 +211,7 @@ function renderList(id, list) {
const el = document.createElement('div');
el.className = 'card';
el.onclick = () => {
window.location.href = `/book/${book.id}`;
};

View File

@@ -467,7 +467,7 @@ nextBtn.addEventListener('click', () => {
function updateURL(newChapter) {
chapter = newChapter;
const newUrl = `/reader/${provider}/${chapter}/${bookId}`;
const newUrl = `/read/${provider}/${chapter}/${bookId}`;
window.history.pushState({}, '', newUrl);
}

View File

@@ -7,9 +7,46 @@ const databases = new Map();
const DEFAULT_PATHS = {
anilist: path.join(__dirname, '..', 'metadata', 'anilist_anime.db'),
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db")
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"),
cache: path.join(os.homedir(), "WaifuBoards", "cache.db")
};
async function ensureExtensionsTable(db) {
return new Promise((resolve, reject) => {
db.exec(`
CREATE TABLE IF NOT EXISTS extension (
ext_name TEXT NOT NULL,
id TEXT NOT NULL,
title TEXT NOT NULL,
metadata TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY(ext_name, id)
);
`, (err) => {
if (err) reject(err);
else resolve(true);
});
});
}
async function ensureCacheTable(db) {
return new Promise((resolve, reject) => {
db.exec(`
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
result TEXT NOT NULL,
created_at INTEGER NOT NULL,
ttl_ms INTEGER NOT NULL
);
`, (err) => {
if (err) reject(err);
else resolve(true);
});
});
}
function ensureFavoritesDB(dbPath) {
const dir = path.dirname(dbPath);
@@ -25,7 +62,6 @@ function ensureFavoritesDB(dbPath) {
);
return new Promise((resolve, reject) => {
if (!exists) {
const schema = `
CREATE TABLE IF NOT EXISTS favorites (
@@ -56,15 +92,11 @@ function ensureFavoritesDB(dbPath) {
const queries = [];
if (!hasHeaders) {
queries.push(
`ALTER TABLE favorites ADD COLUMN headers TEXT NOT NULL DEFAULT ""`
);
queries.push(`ALTER TABLE favorites ADD COLUMN headers TEXT NOT NULL DEFAULT ""`);
}
if (!hasProvider) {
queries.push(
`ALTER TABLE favorites ADD COLUMN provider TEXT NOT NULL DEFAULT ""`
);
queries.push(`ALTER TABLE favorites ADD COLUMN provider TEXT NOT NULL DEFAULT ""`);
}
if (queries.length === 0) {
@@ -91,6 +123,13 @@ function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
.catch(err => console.error("Error creando favorites:", err));
}
if (name === "cache") {
const dir = path.dirname(finalPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
const mode = readOnly ? sqlite3.OPEN_READONLY : (sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE);
const db = new sqlite3.Database(finalPath, mode, (err) => {
@@ -102,12 +141,25 @@ function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
});
databases.set(name, db);
if (name === "anilist") {
ensureExtensionsTable(db)
.catch(err => console.error("Error creating extension table:", err));
}
if (name === "cache") {
ensureCacheTable(db)
.catch(err => console.error("Error creating cache table:", err));
}
return db;
}
function getDatabase(name = 'anilist') {
if (!databases.has(name)) {
return initDatabase(name, null, name === 'anilist');
const readOnly = (name === 'anilist');
return initDatabase(name, null, readOnly);
}
return databases.get(name);
}
@@ -156,11 +208,73 @@ function closeDatabase(name = null) {
}
}
async function getCachedExtension(extName, id) {
return queryOne(
"SELECT metadata FROM extension WHERE ext_name = ? AND id = ?",
[extName, id]
);
}
async function cacheExtension(extName, id, title, metadata) {
return run(
`
INSERT INTO extension (ext_name, id, title, metadata, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(ext_name, id)
DO UPDATE SET
title = excluded.title,
metadata = excluded.metadata,
updated_at = ?
`,
[extName, id, title, JSON.stringify(metadata), Date.now(), Date.now()]
);
}
async function getExtensionTitle(extName, id) {
const sql = "SELECT title FROM extension WHERE ext_name = ? AND id = ?";
const row = await queryOne(sql, [extName, id], 'anilist');
return row ? row.title : null;
}
async function deleteExtension(extName) {
return run(
"DELETE FROM extension WHERE ext_name = ?",
[extName]
);
}
async function getCache(key) {
return queryOne("SELECT result, created_at, ttl_ms FROM cache WHERE key = ?", [key], "cache");
}
async function setCache(key, result, ttl_ms) {
return run(
`
INSERT INTO cache (key, result, created_at, ttl_ms)
VALUES (?, ?, ?, ?)
ON CONFLICT(key)
DO UPDATE SET result = excluded.result, created_at = excluded.created_at, ttl_ms = excluded.ttl_ms
`,
[key, JSON.stringify(result), Date.now(), ttl_ms],
"cache"
);
}
module.exports = {
initDatabase,
getDatabase,
queryOne,
queryAll,
run,
closeDatabase
getCachedExtension,
cacheExtension,
getExtensionTitle,
deleteExtension,
closeDatabase,
getCache,
setCache
};

View File

@@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { queryAll, run } = require('./database');
const extensions = new Map();
@@ -24,11 +25,16 @@ async function loadExtensions() {
delete require.cache[require.resolve(filePath)];
const ExtensionClass = require(filePath);
const instance = typeof ExtensionClass === 'function'
? new ExtensionClass()
: (ExtensionClass.default ? new ExtensionClass.default() : null);
if (instance && (instance.type === "anime-board" || instance.type === "book-board" || instance.type === "image-board")) {
if (instance &&
(instance.type === "anime-board" ||
instance.type === "book-board" ||
instance.type === "image-board")) {
const name = instance.constructor.name;
extensions.set(name, instance);
console.log(`📦 Loaded Extension: ${name}`);
@@ -40,6 +46,22 @@ async function loadExtensions() {
}
console.log(`✅ Loaded ${extensions.size} extensions`);
try {
const loaded = Array.from(extensions.keys());
const rows = await queryAll("SELECT DISTINCT ext_name FROM extension");
for (const row of rows) {
if (!loaded.includes(row.ext_name)) {
console.log(`🧹 Cleaning cached metadata for removed extension: ${row.ext_name}`);
await run("DELETE FROM extension WHERE ext_name = ?", [row.ext_name]);
}
}
} catch (err) {
console.error("❌ Error cleaning extension cache:", err);
}
} catch (err) {
console.error("❌ Extension Scan Error:", err);
}

View File

@@ -61,6 +61,8 @@ export interface ExtensionSearchOptions {
}
export interface ExtensionSearchResult {
format: string;
headers: any;
id: string;
title: string;
image?: string;
@@ -88,6 +90,7 @@ export interface ChapterWithProvider extends Chapter {
}
export interface Extension {
getMetadata: any;
type: 'anime-board' | 'book-board' | 'manga-board';
mediaType?: 'manga' | 'ln';
search?: (options: ExtensionSearchOptions) => Promise<ExtensionSearchResult[]>;

View File

@@ -337,3 +337,25 @@ body {
width: 100%; border-radius: 0; max-height: 60vh; border: none; z-index: 2001;
}
}
.adv-search-btn {
position: absolute;
top: 50%; /* Centrado verticalmente */
right: 5px; /* Ajusta la distancia del borde derecho */
transform: translateY(-50%); /* Ajuste fino de centrado */
/* Estilos para que parezca un botón de icono */
background: transparent;
border: none;
cursor: pointer;
padding: 5px; /* Área de clic cómoda */
line-height: 0; /* Elimina espacio extra */
/* Opcional: Dale un color de icono que combine */
transition: color 0.2s;
}
/* 3. Efecto al pasar el ratón (hover) */
.adv-search-btn:hover {
color: var(--color-primary, #fff); /* Cambia de color al pasar el mouse */
}