code organisation & refactor

This commit is contained in:
2025-11-27 20:23:00 +01:00
parent 03636fee99
commit 2b29beeeb5
33 changed files with 2099 additions and 1947 deletions

View File

@@ -0,0 +1,97 @@
const animeService = require('./anime.service');
const { getExtension, getExtensionsList } = require('../shared/extensions');
async function getAnime(req, reply) {
try {
const { id } = req.params;
const anime = await animeService.getAnimeById(id);
return anime;
} catch (err) {
return { error: "Database error" };
}
}
async function getTrending(req, reply) {
try {
const results = await animeService.getTrendingAnime();
return { results };
} catch (err) {
return { results: [] };
}
}
async function getTopAiring(req, reply) {
try {
const results = await animeService.getTopAiringAnime();
return { results };
} catch (err) {
return { results: [] };
}
}
async function searchLocal(req, reply) {
try {
const query = req.query.q;
const results = await animeService.searchAnimeLocal(query);
return { results };
} catch (err) {
return { results: [] };
}
}
async function getExtensions(req, reply) {
return { extensions: getExtensionsList() };
}
async function getExtensionSettings(req, reply) {
const { name } = req.params;
const ext = getExtension(name);
if (!ext) {
return { error: "Extension not found" };
}
if (!ext.getSettings) {
return { episodeServers: ["default"], supportsDub: false };
}
return ext.getSettings();
}
async function getWatchStream(req, reply) {
try {
const { animeId, episode, server, category, ext } = req.query;
const extension = getExtension(ext);
if (!extension) {
return { error: "Extension not found" };
}
const animeData = await animeService.getAnimeById(animeId);
if (animeData.error) {
return { error: "Anime metadata not found" };
}
const streamData = await animeService.getStreamData(
extension,
animeData,
episode,
server,
category
);
return streamData;
} catch (err) {
return { error: err.message };
}
}
module.exports = {
getAnime,
getTrending,
getTopAiring,
searchLocal,
getExtensions,
getExtensionSettings,
getWatchStream
};

14
src/anime/anime.routes.js Normal file
View File

@@ -0,0 +1,14 @@
const controller = require('./anime.controller');
async function animeRoutes(fastify, options) {
fastify.get('/anime/:id', controller.getAnime);
fastify.get('/trending', controller.getTrending);
fastify.get('/top-airing', controller.getTopAiring);
fastify.get('/search/local', controller.searchLocal);
fastify.get('/extensions', controller.getExtensions);
fastify.get('/extension/:name/settings', controller.getExtensionSettings);
fastify.get('/watch/stream', controller.getWatchStream);
}
module.exports = animeRoutes;

View File

@@ -0,0 +1,85 @@
const { queryOne, queryAll } = require('../shared/database');
async function getAnimeById(id) {
const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]);
if (!row) {
return { error: "Anime not found" };
}
return JSON.parse(row.full_data);
}
async function getTrendingAnime() {
const rows = await queryAll("SELECT full_data FROM trending ORDER BY rank ASC LIMIT 10");
return rows.map(r => JSON.parse(r.full_data));
}
async function getTopAiringAnime() {
const rows = await queryAll("SELECT full_data FROM top_airing ORDER BY rank ASC LIMIT 10");
return rows.map(r => JSON.parse(r.full_data));
}
async function searchAnimeLocal(query) {
if (!query || query.length < 2) {
return [];
}
const sql = `SELECT full_data FROM anime WHERE full_data LIKE ? LIMIT 50`;
const rows = await queryAll(sql, [`%${query}%`]);
const results = rows.map(row => JSON.parse(row.full_data));
const cleanResults = results.filter(anime => {
const q = query.toLowerCase();
const titles = [
anime.title.english,
anime.title.romaji,
anime.title.native,
...(anime.synonyms || [])
].filter(Boolean).map(t => t.toLowerCase());
return titles.some(t => t.includes(q));
});
return cleanResults.slice(0, 10);
}
async function getStreamData(extension, animeData, episode, server, category) {
const searchOptions = {
query: animeData.title.english || animeData.title.romaji,
dub: category === 'dub',
media: {
romajiTitle: animeData.title.romaji,
englishTitle: animeData.title.english || "",
startDate: animeData.startDate || { year: 0, month: 0, day: 0 }
}
};
const searchResults = await extension.search(searchOptions);
if (!searchResults || searchResults.length === 0) {
throw new Error("Anime not found on provider");
}
const bestMatch = searchResults[0];
const episodes = await extension.findEpisodes(bestMatch.id);
const targetEp = episodes.find(e => e.number === parseInt(episode));
if (!targetEp) {
throw new Error("Episode not found");
}
const serverName = server || "default";
const streamData = await extension.findEpisodeServer(targetEp, serverName);
return streamData;
}
module.exports = {
getAnimeById,
getTrendingAnime,
getTopAiringAnime,
searchAnimeLocal,
getStreamData
};

View File

@@ -0,0 +1,90 @@
const booksService = require('./books.service');
async function getBook(req, reply) {
try {
const { id } = req.params;
const book = await booksService.getBookById(id);
return book;
} catch (err) {
return { error: "Fetch error" };
}
}
async function getTrending(req, reply) {
try {
const results = await booksService.getTrendingBooks();
return { results };
} catch (err) {
return { results: [] };
}
}
async function getPopular(req, reply) {
try {
const results = await booksService.getPopularBooks();
return { results };
} catch (err) {
return { results: [] };
}
}
async function searchBooks(req, reply) {
try {
const query = req.query.q;
const dbResults = await booksService.searchBooksLocal(query);
if (dbResults.length > 0) {
return { results: dbResults };
}
console.log(`[Books] Local DB miss for "${query}", fetching live...`);
const anilistResults = await booksService.searchBooksAniList(query);
if (anilistResults.length > 0) {
return { results: anilistResults };
}
const extResults = await booksService.searchBooksExtensions(query);
return { results: extResults };
} catch(e) {
console.error("Search Error:", e.message);
return { results: [] };
}
}
async function getChapters(req, reply) {
try {
const { id } = req.params;
const chapters = await booksService.getChaptersForBook(id);
return chapters;
} catch (err) {
return { chapters: [] };
}
}
async function getChapterContent(req, reply) {
try {
const { bookId, chapter, provider } = req.params;
const content = await booksService.getChapterContent(
bookId,
chapter,
provider
);
return reply.send(content);
} catch (err) {
console.error("getChapterContent error:", err.message);
return reply.code(500).send({ error: "Error loading chapter" });
}
}
module.exports = {
getBook,
getTrending,
getPopular,
searchBooks,
getChapters,
getChapterContent
};

13
src/books/books.routes.js Normal file
View File

@@ -0,0 +1,13 @@
const controller = require('./books.controller');
async function booksRoutes(fastify, options) {
fastify.get('/book/:id', controller.getBook);
fastify.get('/books/trending', controller.getTrending);
fastify.get('/books/popular', controller.getPopular);
fastify.get('/search/books', controller.searchBooks);
fastify.get('/book/:id/chapters', controller.getChapters);
fastify.get('/book/:bookId/:chapter/:provider', controller.getChapterContent);
}
module.exports = booksRoutes;

289
src/books/books.service.js Normal file
View File

@@ -0,0 +1,289 @@
const { queryOne, queryAll } = require('../shared/database');
const { getAllExtensions } = require('../shared/extensions');
async function getBookById(id) {
const row = await queryOne("SELECT full_data FROM books WHERE id = ?", [id]);
if (row) {
return JSON.parse(row.full_data);
}
try {
console.log(`[Book] Local miss for ID ${id}, fetching live...`);
const query = `
query ($id: Int) {
Media(id: $id, type: MANGA) {
id idMal title { romaji english native userPreferred } type format status description
startDate { year month day } endDate { year month day } season seasonYear seasonInt
episodes duration chapters volumes countryOfOrigin isLicensed source hashtag
trailer { id site thumbnail } updatedAt coverImage { extraLarge large medium color }
bannerImage genres synonyms averageScore meanScore popularity isLocked trending favourites
tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult userId }
relations { edges { relationType node { id title { romaji } } } }
characters(page: 1, perPage: 10) { nodes { id name { full } } }
studios { nodes { id name isAnimationStudio } }
isAdult nextAiringEpisode { airingAt timeUntilAiring episode }
externalLinks { url site }
rankings { id rank type format year season allTime context }
}
}`;
const response = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ query, variables: { id: parseInt(id) } })
});
const data = await response.json();
if (data.data && data.data.Media) {
return data.data.Media;
}
} catch(e) {
console.error("Fetch error:", e);
}
return { error: "Book not found" };
}
async function getTrendingBooks() {
const rows = await queryAll("SELECT full_data FROM trending_books ORDER BY rank ASC LIMIT 10");
return rows.map(r => JSON.parse(r.full_data));
}
async function getPopularBooks() {
const rows = await queryAll("SELECT full_data FROM popular_books ORDER BY rank ASC LIMIT 10");
return rows.map(r => JSON.parse(r.full_data));
}
async function searchBooksLocal(query) {
if (!query || query.length < 2) {
return [];
}
const sql = `SELECT full_data FROM books WHERE full_data LIKE ? LIMIT 50`;
const rows = await queryAll(sql, [`%${query}%`]);
const results = rows.map(row => JSON.parse(row.full_data));
const clean = results.filter(book => {
const searchTerms = [
book.title.english,
book.title.romaji,
book.title.native,
...(book.synonyms || [])
].filter(Boolean).map(t => t.toLowerCase());
return searchTerms.some(term => term.includes(query.toLowerCase()));
});
return clean.slice(0, 10);
}
async function searchBooksAniList(query) {
const gql = `
query ($search: String) {
Page(page: 1, perPage: 5) {
media(search: $search, type: MANGA, isAdult: false) {
id title { romaji english native }
coverImage { extraLarge large }
bannerImage description averageScore format
seasonYear startDate { year }
}
}
}`;
const response = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ query: gql, variables: { search: query } })
});
const liveData = await response.json();
if (liveData.data && liveData.data.Page.media.length > 0) {
return liveData.data.Page.media;
}
return [];
}
async function searchBooksExtensions(query) {
const extensions = getAllExtensions();
for (const [name, ext] of extensions) {
if ((ext.type === 'book-board' || ext.type === 'manga-board') && ext.search) {
try {
console.log(`[${name}] Searching for book: ${query}`);
const matches = await ext.search({
query: query,
media: {
romajiTitle: query,
englishTitle: query,
startDate: { year: 0, month: 0, day: 0 }
}
});
if (matches && matches.length > 0) {
return matches.map(m => ({
id: m.id,
title: { romaji: m.title, english: m.title },
coverImage: { large: m.image || '' },
averageScore: m.rating || m.score || null,
format: 'MANGA',
seasonYear: null,
isExtensionResult: true
}));
}
} catch (e) {
console.error(`Extension search failed for ${name}:`, e);
}
}
}
return [];
}
async function getChaptersForBook(id) {
let bookData = await queryOne("SELECT full_data FROM books WHERE id = ?", [id])
.then(row => row ? JSON.parse(row.full_data) : null)
.catch(() => null);
if (!bookData) {
try {
const query = `query ($id: Int) { Media(id: $id, type: MANGA) { title { romaji english } startDate { year month day } } }`;
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables: { id: parseInt(id) } })
});
const d = await res.json();
if(d.data?.Media) bookData = d.data.Media;
} catch(e) {}
}
if (!bookData) return { chapters: [] };
const titles = [bookData.title.english, bookData.title.romaji].filter(t => t);
const searchTitle = titles[0];
const allChapters = [];
const extensions = getAllExtensions();
const searchPromises = Array.from(extensions.entries())
.filter(([name, ext]) => (ext.type === 'book-board' || ext.type === 'manga-board') && ext.search && ext.findChapters)
.map(async ([name, ext]) => {
try {
console.log(`[${name}] Searching chapters for: ${searchTitle}`);
const matches = await ext.search({
query: searchTitle,
media: {
romajiTitle: bookData.title.romaji,
englishTitle: bookData.title.english,
startDate: bookData.startDate
}
});
if (matches && matches.length > 0) {
const best = matches[0];
const chaps = await ext.findChapters(best.id);
if (chaps && chaps.length > 0) {
console.log(`[${name}] Found ${chaps.length} chapters.`);
chaps.forEach(ch => {
const num = parseFloat(ch.number);
allChapters.push({
id: ch.id,
number: num,
title: ch.title,
date: ch.releaseDate,
provider: name
});
});
}
} else {
console.log(`[${name}] No matches found for book.`);
}
} catch (e) {
console.error(`Failed to fetch chapters from ${name}:`, e.message);
}
});
await Promise.all(searchPromises);
return { chapters: allChapters.sort((a, b) => a.number - b.number) };
}
async function getChapterContent(bookId, chapterIndex, providerName) {
const extensions = getAllExtensions();
const ext = extensions.get(providerName);
if (!ext) {
throw new Error("Provider not found");
}
const chapterList = await getChaptersForBook(bookId);
if (!chapterList?.chapters || chapterList.chapters.length === 0) {
throw new Error("Chapters not found");
}
const providerChapters = chapterList.chapters.filter(c => c.provider === providerName);
const index = parseInt(chapterIndex, 10);
if (Number.isNaN(index)) {
throw new Error("Invalid chapter index");
}
if (!providerChapters[index]) {
throw new Error("Chapter index out of range");
}
const selectedChapter = providerChapters[index];
const chapterId = selectedChapter.id;
const chapterTitle = selectedChapter.title || null;
const chapterNumber = typeof selectedChapter.number === 'number' ? selectedChapter.number : index;
try {
if (ext.mediaType === "manga") {
const pages = await ext.findChapterPages(chapterId);
return {
type: "manga",
chapterId,
title: chapterTitle,
number: chapterNumber,
provider: providerName,
pages
};
}
if (ext.mediaType === "ln") {
const content = await ext.findChapterPages(chapterId);
return {
type: "ln",
chapterId,
title: chapterTitle,
number: chapterNumber,
provider: providerName,
content
};
}
throw new Error("Unknown mediaType");
} catch (err) {
console.error(`[Chapter] Error loading from ${providerName}:`, err && err.message ? err.message : err);
throw err;
}
}
module.exports = {
getBookById,
getTrendingBooks,
getPopularBooks,
searchBooksLocal,
searchBooksAniList,
searchBooksExtensions,
getChaptersForBook,
getChapterContent
};

View File

@@ -2,13 +2,11 @@ const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
// --- CONFIGURATION ---
const DB_PATH = path.join(__dirname, 'anilist_anime.db');
const REQUESTS_PER_MINUTE = 20; // 20 RPM is safe (AniList limit is 90)
const REQUESTS_PER_MINUTE = 20;
const DELAY_MS = (60000 / REQUESTS_PER_MINUTE);
const FEATURED_REFRESH_RATE = 8 * 60 * 1000; // 8 Minutes
const FEATURED_REFRESH_RATE = 8 * 60 * 1000;
// Ensure directory exists
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
@@ -16,16 +14,13 @@ if (!fs.existsSync(dir)) {
const db = new sqlite3.Database(DB_PATH);
// --- DATABASE SETUP ---
function initDB() {
return new Promise((resolve, reject) => {
db.serialize(() => {
// 1. Anime Tables
db.run(`CREATE TABLE IF NOT EXISTS anime (id INTEGER PRIMARY KEY, title TEXT, updatedAt INTEGER, full_data JSON)`);
db.run(`CREATE TABLE IF NOT EXISTS trending (rank INTEGER PRIMARY KEY, id INTEGER, full_data JSON)`);
db.run(`CREATE TABLE IF NOT EXISTS top_airing (rank INTEGER PRIMARY KEY, id INTEGER, full_data JSON)`);
// 2. Books Tables (Manga/LN)
db.run(`CREATE TABLE IF NOT EXISTS books (id INTEGER PRIMARY KEY, title TEXT, updatedAt INTEGER, full_data JSON)`);
db.run(`CREATE TABLE IF NOT EXISTS trending_books (rank INTEGER PRIMARY KEY, id INTEGER, full_data JSON)`);
db.run(`CREATE TABLE IF NOT EXISTS popular_books (rank INTEGER PRIMARY KEY, id INTEGER, full_data JSON)`, (err) => {
@@ -36,9 +31,7 @@ function initDB() {
});
}
// --- QUERIES ---
// Exhaustive list of fields
const MEDIA_FIELDS = `
id
idMal
@@ -170,7 +163,6 @@ query ($sort: [MediaSort], $type: MediaType, $status: MediaStatus) {
}
`;
// --- NETWORK HELPERS ---
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -211,7 +203,6 @@ async function fetchGraphQL(query, variables) {
}
}
// --- FUNCTIONS ---
function saveMediaBatch(tableName, mediaList) {
return new Promise((resolve, reject) => {
@@ -259,7 +250,6 @@ function getLocalCount(tableName) {
return new Promise((resolve) => db.get(`SELECT COUNT(*) as count FROM ${tableName}`, (err, row) => resolve(row ? row.count : 0)));
}
// --- LOOPS ---
async function startFeaturedLoop() {
console.log(`✨ Starting Featured Content Loop (Refreshes every ${FEATURED_REFRESH_RATE / 60000} mins)`);
@@ -267,28 +257,24 @@ async function startFeaturedLoop() {
const runUpdate = async () => {
console.log("🔄 Refreshing Featured tables (Anime & Books)...");
// 1. Anime Trending
const animeTrending = await fetchGraphQL(FEATURED_QUERY, { sort: "TRENDING_DESC", type: "ANIME" });
if (animeTrending && animeTrending.media) {
await updateFeaturedTable('trending', animeTrending.media);
console.log(` ✅ Updated Anime Trending.`);
}
// 2. Anime Top Airing
const animeTop = await fetchGraphQL(FEATURED_QUERY, { sort: "SCORE_DESC", type: "ANIME", status: "RELEASING" });
if (animeTop && animeTop.media) {
await updateFeaturedTable('top_airing', animeTop.media);
console.log(` ✅ Updated Anime Top Airing.`);
}
// 3. Books Trending
const mangaTrending = await fetchGraphQL(FEATURED_QUERY, { sort: "TRENDING_DESC", type: "MANGA" });
if (mangaTrending && mangaTrending.media) {
await updateFeaturedTable('trending_books', mangaTrending.media);
console.log(` ✅ Updated Books Trending.`);
}
// 4. Books Popular
const mangaPop = await fetchGraphQL(FEATURED_QUERY, { sort: "POPULARITY_DESC", type: "MANGA" });
if (mangaPop && mangaPop.media) {
await updateFeaturedTable('popular_books', mangaPop.media);
@@ -343,12 +329,10 @@ async function startScraper(type, tableName) {
}
}
// --- MAIN ENTRY ---
async function animeMetadata() {
await initDB();
// Start loops
startFeaturedLoop();
startFeaturedLoop();
startScraper('ANIME', 'anime');
startScraper('MANGA', 'books');
}

178
src/scripts/anime/anime.js Normal file
View File

@@ -0,0 +1,178 @@
const animeId = window.location.pathname.split('/').pop();
let player;
let totalEpisodes = 0;
let currentPage = 1;
const itemsPerPage = 12;
var tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
var firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
async function loadAnime() {
try {
const res = await fetch(`/api/anime/${animeId}`);
const data = await res.json();
if(data.error) {
document.getElementById('title').innerText = "Anime Not Found";
return;
}
const title = data.title.english || data.title.romaji;
document.title = `${title} | WaifuBoard`;
document.getElementById('title').innerText = title;
document.getElementById('poster').src = data.coverImage.extraLarge;
const rawDesc = data.description || "No description available.";
handleDescription(rawDesc);
document.getElementById('score').innerText = (data.averageScore || '?') + '% Score';
document.getElementById('year').innerText = data.seasonYear || '????';
document.getElementById('genres').innerText = data.genres ? data.genres.slice(0, 3).join(' • ') : '';
document.getElementById('format').innerText = data.format || 'TV';
document.getElementById('episodes').innerText = data.episodes || '?';
document.getElementById('status').innerText = data.status || 'Unknown';
document.getElementById('season').innerText = `${data.season || ''} ${data.seasonYear || ''}`;
if (data.studios && data.studios.nodes.length > 0) {
document.getElementById('studio').innerText = data.studios.nodes[0].name;
}
if (data.characters && data.characters.nodes) {
const charContainer = document.getElementById('char-list');
data.characters.nodes.slice(0, 5).forEach(char => {
charContainer.innerHTML += `
<div class="character-item">
<div class="char-dot"></div> ${char.name.full}
</div>`;
});
}
document.getElementById('watch-btn').onclick = () => {
window.location.href = `/watch/${animeId}/1`;
};
if (data.trailer && data.trailer.site === 'youtube') {
window.onYouTubeIframeAPIReady = function() {
player = new YT.Player('player', {
height: '100%',
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
},
events: { 'onReady': (e) => e.target.playVideo() }
});
};
} else {
const banner = data.bannerImage || data.coverImage.extraLarge;
document.querySelector('.video-background').innerHTML = `<img src="${banner}" style="width:100%; height:100%; object-fit:cover;">`;
}
if (data.nextAiringEpisode) {
totalEpisodes = data.nextAiringEpisode.episode - 1;
} else {
totalEpisodes = data.episodes || 12;
}
totalEpisodes = Math.min(Math.max(totalEpisodes, 1), 5000);
renderEpisodes();
} catch (err) {
console.error(err);
}
}
function handleDescription(text) {
const tmp = document.createElement("DIV");
tmp.innerHTML = text;
const cleanText = tmp.textContent || tmp.innerText || "";
const sentences = cleanText.match(/[^\.!\?]+[\.!\?]+/g) || [cleanText];
document.getElementById('full-description').innerHTML = text;
if (sentences.length > 4) {
const shortText = sentences.slice(0, 4).join(' ');
document.getElementById('description-preview').innerText = shortText + '...';
document.getElementById('read-more-btn').style.display = 'inline-flex';
} else {
document.getElementById('description-preview').innerHTML = text;
document.getElementById('read-more-btn').style.display = 'none';
}
}
function openModal() {
document.getElementById('desc-modal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeModal() {
document.getElementById('desc-modal').classList.remove('active');
document.body.style.overflow = '';
}
document.getElementById('desc-modal').addEventListener('click', (e) => {
if (e.target.id === 'desc-modal') closeModal();
});
function renderEpisodes() {
const grid = document.getElementById('episodes-grid');
grid.innerHTML = '';
const start = (currentPage - 1) * itemsPerPage + 1;
const end = Math.min(start + itemsPerPage - 1, totalEpisodes);
for(let i = start; i <= end; i++) {
createEpisodeButton(i, grid);
}
updatePaginationControls();
}
function createEpisodeButton(num, container) {
const btn = document.createElement('div');
btn.className = 'episode-btn';
btn.innerText = `Ep ${num}`;
btn.onclick = () => window.location.href = `/watch/${animeId}/${num}`;
container.appendChild(btn);
}
function updatePaginationControls() {
const totalPages = Math.ceil(totalEpisodes / itemsPerPage);
document.getElementById('page-info').innerText = `Page ${currentPage} of ${totalPages}`;
document.getElementById('prev-page').disabled = currentPage === 1;
document.getElementById('next-page').disabled = currentPage === totalPages;
document.getElementById('pagination-controls').style.display = 'flex';
}
function changePage(delta) {
currentPage += delta;
renderEpisodes();
}
const searchInput = document.getElementById('ep-search');
searchInput.addEventListener('input', (e) => {
const val = parseInt(e.target.value);
const grid = document.getElementById('episodes-grid');
if (val > 0 && val <= totalEpisodes) {
grid.innerHTML = '';
createEpisodeButton(val, grid);
document.getElementById('pagination-controls').style.display = 'none';
} else if (!e.target.value) {
renderEpisodes();
} else {
grid.innerHTML = '<div style="color:#666; width:100%; text-align:center;">Episode not found</div>';
document.getElementById('pagination-controls').style.display = 'none';
}
});
loadAnime();

210
src/scripts/anime/animes.js Normal file
View File

@@ -0,0 +1,210 @@
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
let searchTimeout;
searchInput.addEventListener('input', (e) => {
const query = e.target.value;
clearTimeout(searchTimeout);
if (query.length < 2) {
searchResults.classList.remove('active');
searchResults.innerHTML = '';
searchInput.style.borderRadius = '99px';
return;
}
searchTimeout = setTimeout(() => {
fetchLocalSearch(query);
}, 300);
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-wrapper')) {
searchResults.classList.remove('active');
searchInput.style.borderRadius = '99px';
}
});
async function fetchLocalSearch(query) {
try {
const res = await fetch(`/api/search/local?q=${encodeURIComponent(query)}`);
const data = await res.json();
renderSearchResults(data.results);
} catch (err) { console.error("Search Error:", err); }
}
function renderSearchResults(results) {
searchResults.innerHTML = '';
if (results.length === 0) {
searchResults.innerHTML = '<div style="padding:1rem; color:#888; text-align:center">No results found</div>';
} else {
results.forEach(anime => {
const title = getTitle(anime);
const img = anime.coverImage.medium || anime.coverImage.large;
const rating = anime.averageScore ? `${anime.averageScore}%` : 'N/A';
const year = anime.seasonYear || '';
const format = anime.format || 'TV';
const item = document.createElement('a');
item.className = 'search-item';
item.href = `/anime/${anime.id}`;
item.innerHTML = `
<img src="${img}" class="search-poster" alt="${title}">
<div class="search-info">
<div class="search-title">${title}</div>
<div class="search-meta">
<span class="rating-pill">${rating}</span>
<span>• ${year}</span>
<span>• ${format}</span>
</div>
</div>
`;
searchResults.appendChild(item);
});
}
searchResults.classList.add('active');
searchInput.style.borderRadius = '12px 12px 0 0';
}
function scrollCarousel(id, direction) {
const container = document.getElementById(id);
if(container) {
const scrollAmount = container.clientWidth * 0.75;
container.scrollBy({ left: direction * scrollAmount, behavior: 'smooth' });
}
}
let trendingAnimes = [];
let currentHeroIndex = 0;
let player;
let heroInterval;
var tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
var firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
function onYouTubeIframeAPIReady() {
player = new YT.Player('player', {
height: '100%', width: '100%',
playerVars: { 'autoplay': 1, 'controls': 0, 'mute': 1, 'loop': 1, 'showinfo': 0, 'modestbranding': 1 },
events: { 'onReady': (e) => { e.target.mute(); if(trendingAnimes.length) updateHeroVideo(trendingAnimes[currentHeroIndex]); } }
});
}
async function fetchContent(isUpdate = false) {
try {
const trendingRes = await fetch('/api/trending');
const trendingData = await trendingRes.json();
if (trendingData.results && trendingData.results.length > 0) {
trendingAnimes = trendingData.results;
if (!isUpdate) {
updateHeroUI(trendingAnimes[0]);
startHeroCycle();
}
renderList('trending', trendingAnimes);
} else if (!isUpdate) {
setTimeout(() => fetchContent(false), 2000);
}
const topRes = await fetch('/api/top-airing');
const topData = await topRes.json();
if (topData.results && topData.results.length > 0) {
renderList('top-airing', topData.results);
}
} catch (e) {
console.error("Fetch Error:", e);
if(!isUpdate) setTimeout(() => fetchContent(false), 5000);
}
}
function startHeroCycle() {
if(heroInterval) clearInterval(heroInterval);
heroInterval = setInterval(() => {
if(trendingAnimes.length > 0) {
currentHeroIndex = (currentHeroIndex + 1) % trendingAnimes.length;
updateHeroUI(trendingAnimes[currentHeroIndex]);
}
}, 10000);
}
function getTitle(anime) {
return anime.title.english || anime.title.romaji || "Unknown Title";
}
function updateHeroUI(anime) {
if(!anime) return;
const title = getTitle(anime);
const score = anime.averageScore ? anime.averageScore + '% Match' : 'N/A';
const year = anime.seasonYear || '';
const type = anime.format || 'TV';
const desc = anime.description || 'No description available.';
const poster = anime.coverImage ? anime.coverImage.extraLarge : '';
const banner = anime.bannerImage || poster;
document.getElementById('hero-title').innerText = title;
document.getElementById('hero-desc').innerHTML = desc;
document.getElementById('hero-score').innerText = score;
document.getElementById('hero-year').innerText = year;
document.getElementById('hero-type').innerText = type;
document.getElementById('hero-poster').src = poster;
const watchBtn = document.getElementById('watch-btn');
if(watchBtn) watchBtn.onclick = () => window.location.href = `/anime/${anime.id}`;
const bgImg = document.getElementById('hero-bg-media');
if(bgImg && bgImg.src !== banner) bgImg.src = banner;
updateHeroVideo(anime);
document.getElementById('hero-loading-ui').style.display = 'none';
document.getElementById('hero-real-ui').style.display = 'block';
}
function updateHeroVideo(anime) {
if (!player || !player.loadVideoById) return;
const videoContainer = document.getElementById('player');
if (anime.trailer && anime.trailer.site === 'youtube' && anime.trailer.id) {
if(player.getVideoData && player.getVideoData().video_id !== anime.trailer.id) {
player.loadVideoById(anime.trailer.id);
player.mute();
}
videoContainer.style.opacity = "1";
} else {
videoContainer.style.opacity = "0";
player.stopVideo();
}
}
function renderList(id, list) {
const container = document.getElementById(id);
const firstId = list.length > 0 ? list[0].id : null;
const currentFirstId = container.firstElementChild?.dataset?.id;
if (currentFirstId && parseInt(currentFirstId) === firstId && container.children.length === list.length) {
return;
}
container.innerHTML = '';
list.forEach(anime => {
const title = getTitle(anime);
const cover = anime.coverImage ? anime.coverImage.large : '';
const ep = anime.nextAiringEpisode ? 'Ep ' + anime.nextAiringEpisode.episode : (anime.episodes ? anime.episodes + ' Eps' : 'TV');
const score = anime.averageScore || '--';
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>
<div class="card-content">
<h3>${title}</h3>
<p>${score}% • ${ep}</p>
</div>
`;
container.appendChild(el);
});
}
fetchContent();
setInterval(() => fetchContent(true), 60000);

211
src/scripts/anime/player.js Normal file
View File

@@ -0,0 +1,211 @@
const pathParts = window.location.pathname.split('/');
const animeId = pathParts[2];
const currentEpisode = parseInt(pathParts[3]);
let audioMode = 'sub';
let currentExtension = '';
let plyrInstance;
let hlsInstance;
document.getElementById('back-link').href = `/anime/${animeId}`;
document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`;
async function loadMetadata() {
try {
const res = await fetch(`/api/anime/${animeId}`);
const data = await res.json();
if(!data.error) {
const title = data.title.english || data.title.romaji;
document.getElementById('anime-title').innerText = title;
document.title = `Watching ${title} - Ep ${currentEpisode}`;
}
} catch(e) { console.error(e); }
}
async function loadExtensions() {
try {
const res = await fetch('/api/extensions');
const data = await res.json();
const select = document.getElementById('extension-select');
if (data.extensions && data.extensions.length > 0) {
data.extensions.forEach(extName => {
const opt = document.createElement('option');
opt.value = extName;
opt.innerText = extName;
select.appendChild(opt);
});
} else {
select.innerHTML = '<option>No Extensions</option>';
select.disabled = true;
setLoading("No extensions found in WaifuBoards folder.");
}
} catch(e) { console.error("Extension Error:", e); }
}
async function onExtensionChange() {
const select = document.getElementById('extension-select');
currentExtension = select.value;
setLoading("Fetching extension settings...");
try {
const res = await fetch(`/api/extension/${currentExtension}/settings`);
const settings = await res.json();
const toggle = document.getElementById('sd-toggle');
if (settings.supportsDub) {
toggle.style.display = 'flex';
setAudioMode('sub');
} else {
toggle.style.display = 'none';
setAudioMode('sub');
}
const serverSelect = document.getElementById('server-select');
serverSelect.innerHTML = '';
if (settings.episodeServers && settings.episodeServers.length > 0) {
settings.episodeServers.forEach(srv => {
const opt = document.createElement('option');
opt.value = srv;
opt.innerText = srv;
serverSelect.appendChild(opt);
});
serverSelect.style.display = 'block';
} else {
serverSelect.style.display = 'none';
}
loadStream();
} catch (err) {
console.error(err);
setLoading("Failed to load extension settings.");
}
}
function toggleAudioMode() {
const newMode = audioMode === 'sub' ? 'dub' : 'sub';
setAudioMode(newMode);
loadStream();
}
function setAudioMode(mode) {
audioMode = mode;
const toggle = document.getElementById('sd-toggle');
const subOpt = document.getElementById('opt-sub');
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');
}
}
async function loadStream() {
if (!currentExtension) return;
const serverSelect = document.getElementById('server-select');
const server = serverSelect.value || "default";
setLoading(`Searching & Resolving Stream (${audioMode})...`);
try {
const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}`;
const res = await fetch(url);
const data = await res.json();
if (data.error) {
setLoading(`Error: ${data.error}`);
return;
}
if (!data.videoSources || data.videoSources.length === 0) {
setLoading("No video sources found.");
return;
}
const source = data.videoSources.find(s => s.type === 'm3u8') || data.videoSources[0];
const headers = data.headers || {};
let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`;
if (headers['Referer']) proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`;
if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`;
if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
playVideo(proxyUrl, data.videoSources[0].subtitles);
document.getElementById('loading-overlay').style.display = 'none';
} catch (err) {
setLoading("Stream Error. Check console.");
console.error(err);
}
}
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.loadSource(url);
hlsInstance.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
}
if (plyrInstance) plyrInstance.destroy();
while (video.firstChild) {
video.removeChild(video.firstChild);
}
if (subtitles && subtitles.length > 0) {
subtitles.forEach(sub => {
const track = document.createElement('track');
track.kind = 'captions';
track.label = sub.language;
track.srclang = sub.language.slice(0, 2).toLowerCase();
track.src = sub.url;
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'],
settings: ['captions', 'quality', 'speed']
});
video.play().catch(e => console.log("Auto-play blocked"));
}
function setLoading(msg) {
const overlay = document.getElementById('loading-overlay');
const text = document.getElementById('loading-text');
overlay.style.display = 'flex';
text.innerText = msg;
}
document.getElementById('prev-btn').onclick = () => {
if(currentEpisode > 1) window.location.href = `/watch/${animeId}/${currentEpisode - 1}`;
};
document.getElementById('next-btn').onclick = () => {
window.location.href = `/watch/${animeId}/${currentEpisode + 1}`;
};
if(currentEpisode <= 1) document.getElementById('prev-btn').disabled = true;
loadMetadata();
loadExtensions();

207
src/scripts/books/book.js Normal file
View File

@@ -0,0 +1,207 @@
const bookId = window.location.pathname.split('/').pop();
let allChapters = [];
let filteredChapters = [];
let currentPage = 1;
const itemsPerPage = 12;
async function init() {
try {
const res = await fetch(`/api/book/${bookId}`);
const data = await res.json();
if (data.error) {
const titleEl = document.getElementById('title');
if (titleEl) titleEl.innerText = "Book Not Found";
return;
}
const title = data.title.english || data.title.romaji;
document.title = `${title} | WaifuBoard Books`;
const titleEl = document.getElementById('title');
if (titleEl) titleEl.innerText = title;
const descEl = document.getElementById('description');
if (descEl) descEl.innerHTML = data.description || "No description available.";
const scoreEl = document.getElementById('score');
if (scoreEl) scoreEl.innerText = (data.averageScore || '?') + '% 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 = '????';
}
}
const statusEl = document.getElementById('status');
if (statusEl) statusEl.innerText = data.status || 'Unknown';
const formatEl = document.getElementById('format');
if (formatEl) formatEl.innerText = data.format || 'MANGA';
const chaptersEl = document.getElementById('chapters');
if (chaptersEl) chaptersEl.innerText = data.chapters || '?';
const genresEl = document.getElementById('genres');
if(genresEl && data.genres) {
genresEl.innerText = data.genres.slice(0, 3).join(' • ');
}
const img = data.coverImage.extraLarge || data.coverImage.large;
const posterEl = document.getElementById('poster');
if (posterEl) posterEl.src = img;
const heroBgEl = document.getElementById('hero-bg');
if (heroBgEl) heroBgEl.src = data.bannerImage || img;
loadChapters();
} catch (err) {
console.error("Metadata Error:", err);
}
}
async function loadChapters() {
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 res = await fetch(`/api/book/${bookId}/chapters`);
const data = await res.json();
allChapters = data.chapters || [];
filteredChapters = [...allChapters];
const totalEl = document.getElementById('total-chapters');
if (allChapters.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters found on loaded extensions.</td></tr>';
if (totalEl) totalEl.innerText = "0 Found";
return;
}
if (totalEl) totalEl.innerText = `${allChapters.length} Found`;
populateProviderFilter();
const readBtn = document.getElementById('read-start-btn');
if (readBtn && filteredChapters.length > 0) {
readBtn.onclick = () => openReader(filteredChapters[0].id);
}
renderTable();
} catch (err) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; color: #ef4444;">Error loading chapters.</td></tr>';
console.error(err);
}
}
function populateProviderFilter() {
const select = document.getElementById('provider-filter');
if (!select) return;
const providers = [...new Set(allChapters.map(ch => ch.provider))];
if (providers.length > 0) {
select.style.display = 'inline-block';
select.innerHTML = '<option value="all">All Providers</option>';
providers.forEach(prov => {
const opt = document.createElement('option');
opt.value = prov;
opt.innerText = prov;
select.appendChild(opt);
});
select.onchange = (e) => {
const selected = e.target.value;
if (selected === 'all') {
filteredChapters = [...allChapters];
} else {
filteredChapters = allChapters.filter(ch => ch.provider === selected);
}
currentPage = 1;
renderTable();
};
}
}
function renderTable() {
const tbody = document.getElementById('chapters-body');
if (!tbody) return;
tbody.innerHTML = '';
if (filteredChapters.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 2rem;">No chapters match this filter.</td></tr>';
updatePagination();
return;
}
const start = (currentPage - 1) * itemsPerPage;
const end = start + itemsPerPage;
const pageItems = filteredChapters.slice(start, end);
pageItems.forEach((ch, idx) => {
const realIndex = start + idx;
const row = document.createElement('tr');
row.innerHTML = `
<td>${ch.number}</td>
<td>${ch.title || 'Chapter ' + ch.number}</td>
<td><span class="pill" style="font-size:0.75rem;">${ch.provider}</span></td>
<td>
<button class="read-btn-small" onclick="openReader('${bookId}', '${realIndex}', '${ch.provider}')">
Read
</button>
</td>
`;
tbody.appendChild(row);
});
updatePagination();
}
function updatePagination() {
const totalPages = Math.ceil(filteredChapters.length / itemsPerPage);
const pagination = document.getElementById('pagination');
if (!pagination) return;
if (totalPages <= 1) {
pagination.style.display = 'none';
return;
}
pagination.style.display = 'flex';
document.getElementById('page-info').innerText = `Page ${currentPage} of ${totalPages}`;
const prevBtn = document.getElementById('prev-page');
const nextBtn = document.getElementById('next-page');
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage >= totalPages;
prevBtn.onclick = () => { currentPage--; renderTable(); };
nextBtn.onclick = () => { currentPage++; renderTable(); };
}
function openReader(bookId, chapterId, provider) {
const c = encodeURIComponent(chapterId);
const p = encodeURIComponent(provider);
window.location.href = `/read/${bookId}/${c}/${p}`;
}
init();

176
src/scripts/books/books.js Normal file
View File

@@ -0,0 +1,176 @@
let trendingBooks = [];
let currentHeroIndex = 0;
let heroInterval;
window.addEventListener('scroll', () => {
const nav = document.getElementById('navbar');
if (window.scrollY > 50) nav.classList.add('scrolled');
else nav.classList.remove('scrolled');
});
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
let searchTimeout;
searchInput.addEventListener('input', (e) => {
const query = e.target.value;
clearTimeout(searchTimeout);
if (query.length < 2) {
searchResults.classList.remove('active');
searchResults.innerHTML = '';
searchInput.style.borderRadius = '99px';
return;
}
searchTimeout = setTimeout(() => {
fetchBookSearch(query);
}, 300);
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-wrapper')) {
searchResults.classList.remove('active');
searchInput.style.borderRadius = '99px';
}
});
async function fetchBookSearch(query) {
try {
const res = await fetch(`/api/search/books?q=${encodeURIComponent(query)}`);
const data = await res.json();
renderSearchResults(data.results || []);
} catch (err) {
console.error("Search Error:", err);
renderSearchResults([]);
}
}
function renderSearchResults(results) {
searchResults.innerHTML = '';
if (!results || results.length === 0) {
searchResults.innerHTML = '<div style="padding:1rem; color:#888; text-align:center">No results found</div>';
} else {
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 year = book.seasonYear || (book.startDate ? book.startDate.year : '') || '????';
const format = book.format || 'MANGA';
const item = document.createElement('a');
item.className = 'search-item';
item.href = `/book/${book.id}`;
item.innerHTML = `
<img src="${img}" class="search-poster" alt="${title}">
<div class="search-info">
<div class="search-title">${title}</div>
<div class="search-meta">
<span class="rating-pill">${rating}</span>
<span>• ${year}</span>
<span>• ${format}</span>
</div>
</div>
`;
searchResults.appendChild(item);
});
}
searchResults.classList.add('active');
searchInput.style.borderRadius = '12px 12px 0 0';
}
function scrollCarousel(id, direction) {
const container = document.getElementById(id);
if(container) {
const scrollAmount = container.clientWidth * 0.75;
container.scrollBy({ left: direction * scrollAmount, behavior: 'smooth' });
}
}
async function init() {
try {
const res = await fetch('/api/books/trending');
const data = await res.json();
if (data.results && data.results.length > 0) {
trendingBooks = data.results;
updateHeroUI(trendingBooks[0]);
renderList('trending', trendingBooks);
startHeroCycle();
}
const resPop = await fetch('/api/books/popular');
const dataPop = await resPop.json();
if (dataPop.results) renderList('popular', dataPop.results);
} catch (e) {
console.error("Books Error:", e);
}
}
function startHeroCycle() {
if(heroInterval) clearInterval(heroInterval);
heroInterval = setInterval(() => {
if(trendingBooks.length > 0) {
currentHeroIndex = (currentHeroIndex + 1) % trendingBooks.length;
updateHeroUI(trendingBooks[currentHeroIndex]);
}
}, 8000);
}
function updateHeroUI(book) {
if(!book) return;
const title = book.title.english || book.title.romaji;
const desc = book.description || "No description available.";
const poster = (book.coverImage && (book.coverImage.extraLarge || book.coverImage.large)) || '';
const banner = book.bannerImage || poster;
document.getElementById('hero-title').innerText = title;
document.getElementById('hero-desc').innerHTML = desc;
document.getElementById('hero-score').innerText = (book.averageScore || '?') + '% Score';
document.getElementById('hero-year').innerText = (book.startDate && book.startDate.year) ? book.startDate.year : '????';
document.getElementById('hero-type').innerText = book.format || 'MANGA';
const heroPoster = document.getElementById('hero-poster');
if(heroPoster) heroPoster.src = poster;
const bg = document.getElementById('hero-bg-media');
if(bg) bg.src = banner;
const readBtn = document.getElementById('read-btn');
if (readBtn) {
readBtn.onclick = () => window.location.href = `/book/${book.id}`;
}
}
function renderList(id, list) {
const container = document.getElementById(id);
container.innerHTML = '';
list.forEach(book => {
const title = book.title.english || book.title.romaji;
const cover = book.coverImage ? book.coverImage.large : '';
const score = book.averageScore || '--';
const type = book.format || 'Book';
const el = document.createElement('div');
el.className = 'card';
el.onclick = () => {
window.location.href = `/book/${book.id}`;
};
el.innerHTML = `
<div class="card-img-wrap"><img src="${cover}" loading="lazy"></div>
<div class="card-content">
<h3>${title}</h3>
<p>${score}% • ${type}</p>
</div>
`;
container.appendChild(el);
});
}
init();

592
src/scripts/books/reader.js Normal file
View File

@@ -0,0 +1,592 @@
const reader = document.getElementById('reader');
const panel = document.getElementById('settings-panel');
const overlay = document.getElementById('overlay');
const settingsBtn = document.getElementById('settings-btn');
const closePanel = document.getElementById('close-panel');
const chapterLabel = document.getElementById('chapter-label');
const prevBtn = document.getElementById('prev-chapter');
const nextBtn = document.getElementById('next-chapter');
const lnSettings = document.getElementById('ln-settings');
const mangaSettings = document.getElementById('manga-settings');
const config = {
ln: {
fontSize: 18,
lineHeight: 1.8,
maxWidth: 750,
fontFamily: '"Georgia", serif',
textColor: '#e5e7eb',
bg: '#14141b',
textAlign: 'justify'
},
manga: {
direction: 'rtl',
mode: 'auto',
spacing: 16,
imageFit: 'screen',
preloadCount: 3
}
};
let currentType = null;
let currentPages = [];
let observer = null;
const parts = window.location.pathname.split('/');
const bookId = parts[2];
let chapter = parts[3];
let provider = parts[4];
function loadConfig() {
try {
const saved = localStorage.getItem('readerConfig');
if (saved) {
const parsed = JSON.parse(saved);
Object.assign(config.ln, parsed.ln || {});
Object.assign(config.manga, parsed.manga || {});
}
} catch (e) {
console.error('Error loading config:', e);
}
updateUIFromConfig();
}
function saveConfig() {
try {
localStorage.setItem('readerConfig', JSON.stringify(config));
} catch (e) {
console.error('Error saving config:', e);
}
}
function updateUIFromConfig() {
document.getElementById('font-size').value = config.ln.fontSize;
document.getElementById('font-size-value').textContent = config.ln.fontSize + 'px';
document.getElementById('line-height').value = config.ln.lineHeight;
document.getElementById('line-height-value').textContent = config.ln.lineHeight;
document.getElementById('max-width').value = config.ln.maxWidth;
document.getElementById('max-width-value').textContent = config.ln.maxWidth + 'px';
document.getElementById('font-family').value = config.ln.fontFamily;
document.getElementById('text-color').value = config.ln.textColor;
document.getElementById('bg-color').value = config.ln.bg;
document.querySelectorAll('[data-align]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.align === config.ln.textAlign);
});
document.getElementById('display-mode').value = config.manga.mode;
document.getElementById('image-fit').value = config.manga.imageFit;
document.getElementById('page-spacing').value = config.manga.spacing;
document.getElementById('page-spacing-value').textContent = config.manga.spacing + 'px';
document.getElementById('preload-count').value = config.manga.preloadCount;
document.querySelectorAll('[data-direction]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.direction === config.manga.direction);
});
}
function applyStyles() {
if (currentType === 'ln') {
document.documentElement.style.setProperty('--ln-font-size', config.ln.fontSize + 'px');
document.documentElement.style.setProperty('--ln-line-height', config.ln.lineHeight);
document.documentElement.style.setProperty('--ln-max-width', config.ln.maxWidth + 'px');
document.documentElement.style.setProperty('--ln-font-family', config.ln.fontFamily);
document.documentElement.style.setProperty('--ln-text-color', config.ln.textColor);
document.documentElement.style.setProperty('--bg-base', config.ln.bg);
document.documentElement.style.setProperty('--ln-text-align', config.ln.textAlign);
}
if (currentType === 'manga') {
document.documentElement.style.setProperty('--page-spacing', config.manga.spacing + 'px');
document.documentElement.style.setProperty('--page-max-width', 900 + 'px');
document.documentElement.style.setProperty('--manga-max-width', 1400 + 'px');
const viewportHeight = window.innerHeight - 64 - 32;
document.documentElement.style.setProperty('--viewport-height', viewportHeight + 'px');
}
}
function updateSettingsVisibility() {
lnSettings.classList.toggle('hidden', currentType !== 'ln');
mangaSettings.classList.toggle('hidden', currentType !== 'manga');
}
async function loadChapter() {
reader.innerHTML = `
<div class="loading-container">
<div class="loading-spinner"></div>
<span>Loading chapter...</span>
</div>
`;
try {
const res = await fetch(`/api/book/${bookId}/${chapter}/${provider}`);
const data = await res.json();
if (data.title) {
chapterLabel.textContent = data.title;
document.title = data.title;
} else {
chapterLabel.textContent = `Chapter ${chapter}`;
document.title = `Chapter ${chapter}`;
}
if (data.error) {
reader.innerHTML = `
<div class="loading-container">
<span style="color: #ef4444;">Error: ${data.error}</span>
</div>
`;
return;
}
currentType = data.type;
updateSettingsVisibility();
applyStyles();
reader.innerHTML = '';
if (data.type === 'manga') {
currentPages = data.pages || [];
loadManga(currentPages);
} else if (data.type === 'ln') {
loadLN(data.content);
}
} catch (error) {
reader.innerHTML = `
<div class="loading-container">
<span style="color: #ef4444;">Error loading chapter: ${error.message}</span>
</div>
`;
}
}
function loadManga(pages) {
if (!pages || pages.length === 0) {
reader.innerHTML = '<div class="loading-container"><span>No pages found</span></div>';
return;
}
const container = document.createElement('div');
container.className = 'manga-container';
let isLongStrip = false;
if (config.manga.mode === 'longstrip') {
isLongStrip = true;
} else if (config.manga.mode === 'auto' && detectLongStrip(pages)) {
isLongStrip = true;
}
const useDouble = config.manga.mode === 'double' ||
(config.manga.mode === 'auto' && !isLongStrip && shouldUseDoublePage(pages));
if (useDouble) {
loadDoublePage(container, pages);
} else {
loadSinglePage(container, pages);
}
reader.appendChild(container);
setupLazyLoading();
enableMangaPageNavigation();
}
function shouldUseDoublePage(pages) {
if (pages.length <= 5) return false;
const widePages = pages.filter(p => {
if (!p.height || !p.width) return false;
const ratio = p.width / p.height;
return ratio > 1.3;
});
if (widePages.length > pages.length * 0.3) return false;
return true;
}
function loadSinglePage(container, pages) {
pages.forEach((page, index) => {
const img = createImageElement(page, index);
container.appendChild(img);
});
}
function loadDoublePage(container, pages) {
let i = 0;
while (i < pages.length) {
const currentPage = pages[i];
const nextPage = pages[i + 1];
const isWide = currentPage.width && currentPage.height &&
(currentPage.width / currentPage.height) > 1.1;
if (isWide) {
const img = createImageElement(currentPage, i);
container.appendChild(img);
i++;
} else {
const doubleContainer = document.createElement('div');
doubleContainer.className = 'double-container';
const leftPage = createImageElement(currentPage, i);
if (nextPage) {
const nextIsWide = nextPage.width && nextPage.height &&
(nextPage.width / nextPage.height) > 1.3;
if (nextIsWide) {
const singleImg = createImageElement(currentPage, i);
container.appendChild(singleImg);
i++;
} else {
const rightPage = createImageElement(nextPage, i + 1);
if (config.manga.direction === 'rtl') {
doubleContainer.appendChild(rightPage);
doubleContainer.appendChild(leftPage);
} else {
doubleContainer.appendChild(leftPage);
doubleContainer.appendChild(rightPage);
}
container.appendChild(doubleContainer);
i += 2;
}
} else {
const singleImg = createImageElement(currentPage, i);
container.appendChild(singleImg);
i++;
}
}
}
}
function createImageElement(page, index) {
const img = document.createElement('img');
img.className = 'page-img';
img.dataset.index = index;
const url = buildProxyUrl(page.url, page.headers);
if (config.manga.mode === 'longstrip' && index > 0) {
img.classList.add('longstrip-fit');
} else {
if (config.manga.imageFit === 'width') img.classList.add('fit-width');
else if (config.manga.imageFit === 'height') img.classList.add('fit-height');
else if (config.manga.imageFit === 'screen') img.classList.add('fit-screen');
}
if (index < config.manga.preloadCount) {
img.src = url;
} else {
img.dataset.src = url;
img.loading = 'lazy';
}
img.alt = `Page ${index + 1}`;
return img;
}
function buildProxyUrl(url, headers = {}) {
const params = new URLSearchParams({
url
});
if (headers.referer) params.append('referer', headers.referer);
if (headers['user-agent']) params.append('ua', headers['user-agent']);
if (headers.cookie) params.append('cookie', headers.cookie);
return `/api/proxy?${params.toString()}`;
}
function detectLongStrip(pages) {
if (!pages || pages.length === 0) return false;
const relevant = pages.slice(1);
const tall = relevant.filter(p => p.height && p.width && (p.height / p.width) > 2);
return tall.length >= 2 || (tall.length / relevant.length) > 0.3;
}
function setupLazyLoading() {
if (observer) observer.disconnect();
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
delete img.dataset.src;
observer.unobserve(img);
}
}
});
}, {
rootMargin: '200px'
});
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));
}
function loadLN(html) {
const div = document.createElement('div');
div.className = 'ln-content';
div.innerHTML = html;
reader.appendChild(div);
}
document.getElementById('font-size').addEventListener('input', (e) => {
config.ln.fontSize = parseInt(e.target.value);
document.getElementById('font-size-value').textContent = e.target.value + 'px';
applyStyles();
saveConfig();
});
document.getElementById('line-height').addEventListener('input', (e) => {
config.ln.lineHeight = parseFloat(e.target.value);
document.getElementById('line-height-value').textContent = e.target.value;
applyStyles();
saveConfig();
});
document.getElementById('max-width').addEventListener('input', (e) => {
config.ln.maxWidth = parseInt(e.target.value);
document.getElementById('max-width-value').textContent = e.target.value + 'px';
applyStyles();
saveConfig();
});
document.getElementById('font-family').addEventListener('change', (e) => {
config.ln.fontFamily = e.target.value;
applyStyles();
saveConfig();
});
document.getElementById('text-color').addEventListener('change', (e) => {
config.ln.textColor = e.target.value;
applyStyles();
saveConfig();
});
document.getElementById('bg-color').addEventListener('change', (e) => {
config.ln.bg = e.target.value;
applyStyles();
saveConfig();
});
document.querySelectorAll('[data-align]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-align]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
config.ln.textAlign = btn.dataset.align;
applyStyles();
saveConfig();
});
});
document.querySelectorAll('[data-preset]').forEach(btn => {
btn.addEventListener('click', () => {
const preset = btn.dataset.preset;
const presets = {
dark: { bg: '#14141b', textColor: '#e5e7eb' },
sepia: { bg: '#f4ecd8', textColor: '#5c472d' },
light: { bg: '#fafafa', textColor: '#1f2937' },
amoled: { bg: '#000000', textColor: '#ffffff' }
};
if (presets[preset]) {
Object.assign(config.ln, presets[preset]);
document.getElementById('bg-color').value = config.ln.bg;
document.getElementById('text-color').value = config.ln.textColor;
applyStyles();
saveConfig();
}
});
});
document.getElementById('display-mode').addEventListener('change', (e) => {
config.manga.mode = e.target.value;
saveConfig();
loadChapter();
});
document.getElementById('image-fit').addEventListener('change', (e) => {
config.manga.imageFit = e.target.value;
saveConfig();
loadChapter();
});
document.getElementById('page-spacing').addEventListener('input', (e) => {
config.manga.spacing = parseInt(e.target.value);
document.getElementById('page-spacing-value').textContent = e.target.value + 'px';
applyStyles();
saveConfig();
});
document.getElementById('preload-count').addEventListener('change', (e) => {
config.manga.preloadCount = parseInt(e.target.value);
saveConfig();
});
document.querySelectorAll('[data-direction]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-direction]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
config.manga.direction = btn.dataset.direction;
saveConfig();
loadChapter();
});
});
prevBtn.addEventListener('click', () => {
const newChapter = String(parseInt(chapter) - 1);
updateURL(newChapter);
window.scrollTo(0, 0);
loadChapter();
});
nextBtn.addEventListener('click', () => {
const newChapter = String(parseInt(chapter) + 1);
updateURL(newChapter);
window.scrollTo(0, 0);
loadChapter();
});
function updateURL(newChapter) {
chapter = newChapter;
const newUrl = `/reader/${bookId}/${chapter}/${provider}`;
window.history.pushState({}, '', newUrl);
}
document.getElementById('back-btn').addEventListener('click', () => {
const parts = window.location.pathname.split('/');
const mangaId = parts[2];
window.location.href = `/book/${mangaId}`;
});
settingsBtn.addEventListener('click', () => {
panel.classList.add('open');
overlay.classList.add('active');
});
closePanel.addEventListener('click', closeSettings);
overlay.addEventListener('click', closeSettings);
function closeSettings() {
panel.classList.remove('open');
overlay.classList.remove('active');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panel.classList.contains('open')) {
closeSettings();
}
});
function enableMangaPageNavigation() {
if (currentType !== 'manga') return;
const logicalPages = [];
document.querySelectorAll('.manga-container > *').forEach(el => {
if (el.classList.contains('double-container')) {
logicalPages.push(el);
} else if (el.tagName === 'IMG') {
logicalPages.push(el);
}
});
if (logicalPages.length === 0) return;
function scrollToLogical(index) {
if (index < 0 || index >= logicalPages.length) return;
const topBar = document.querySelector('.top-bar');
const offset = topBar ? -topBar.offsetHeight : 0;
const y = logicalPages[index].getBoundingClientRect().top
+ window.pageYOffset
+ offset;
window.scrollTo({
top: y,
behavior: 'smooth'
});
}
function getCurrentLogicalIndex() {
let closest = 0;
let minDist = Infinity;
logicalPages.forEach((el, i) => {
const rect = el.getBoundingClientRect();
const dist = Math.abs(rect.top);
if (dist < minDist) {
minDist = dist;
closest = i;
}
});
return closest;
}
const rtl = () => config.manga.direction === 'rtl';
document.addEventListener('keydown', (e) => {
if (currentType !== 'manga') return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
const index = getCurrentLogicalIndex();
if (e.key === 'ArrowLeft') {
scrollToLogical(rtl() ? index + 1 : index - 1);
}
if (e.key === 'ArrowRight') {
scrollToLogical(rtl() ? index - 1 : index + 1);
}
});
reader.addEventListener('click', (e) => {
if (currentType !== 'manga') return;
const bounds = reader.getBoundingClientRect();
const x = e.clientX - bounds.left;
const half = bounds.width / 2;
const index = getCurrentLogicalIndex();
if (x < half) {
scrollToLogical(rtl() ? index + 1 : index - 1);
} else {
scrollToLogical(rtl() ? index - 1 : index + 1);
}
});
}
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
applyStyles();
}, 250);
});
if (!bookId || !chapter || !provider) {
reader.innerHTML = `
<div class="loading-container">
<span style="color: #ef4444;">Missing required parameters (bookId, chapter, provider)</span>
</div>
`;
} else {
loadConfig();
loadChapter();
}

48
src/shared/database.js Normal file
View File

@@ -0,0 +1,48 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const DB_PATH = path.join(__dirname, '..', 'metadata', 'anilist_anime.db');
let db = null;
function initDatabase() {
db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READONLY, (err) => {
if (err) {
console.error("Database Error:", err.message);
} else {
console.log("Connected to local AniList database.");
}
});
return db;
}
function getDatabase() {
if (!db) {
throw new Error("Database not initialized. Call initDatabase() first.");
}
return db;
}
function queryOne(sql, params = []) {
return new Promise((resolve, reject) => {
getDatabase().get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
function queryAll(sql, params = []) {
return new Promise((resolve, reject) => {
getDatabase().all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
module.exports = {
initDatabase,
getDatabase,
queryOne,
queryAll
};

65
src/shared/extensions.js Normal file
View File

@@ -0,0 +1,65 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const extensions = new Map();
async function loadExtensions() {
const homeDir = os.homedir();
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
if (!fs.existsSync(extensionsDir)) {
console.log("⚠️ Extensions directory not found, skipping...");
return;
}
try {
const files = await fs.promises.readdir(extensionsDir);
for (const file of files) {
if (file.endsWith('.js')) {
const filePath = path.join(extensionsDir, file);
try {
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")) {
const name = instance.constructor.name;
extensions.set(name, instance);
console.log(`📦 Loaded Extension: ${name}`);
}
} catch (e) {
console.error(`❌ Failed to load extension ${file}:`, e.message);
}
}
}
console.log(`✅ Loaded ${extensions.size} extensions`);
} catch (err) {
console.error("❌ Extension Scan Error:", err);
}
}
function getExtension(name) {
return extensions.get(name);
}
function getAllExtensions() {
return extensions;
}
function getExtensionsList() {
return Array.from(extensions.keys());
}
module.exports = {
loadExtensions,
getExtension,
getAllExtensions,
getExtensionsList
};

View File

@@ -0,0 +1,47 @@
const { proxyRequest, processM3U8Content, streamToReadable } = require('./proxy.service');
async function proxyRoutes(fastify, options) {
fastify.get('/proxy', async (req, reply) => {
const { url, referer, origin, userAgent } = req.query;
if (!url) {
return reply.code(400).send({ error: "No URL provided" });
}
try {
const { response, contentType, isM3U8 } = await proxyRequest(url, {
referer,
origin,
userAgent
});
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
if (contentType) {
reply.header('Content-Type', contentType);
}
if (isM3U8) {
const text = await response.text();
const baseUrl = new URL(response.url);
const processedContent = processM3U8Content(text, baseUrl, {
referer,
origin,
userAgent
});
return processedContent;
} else {
return reply.send(streamToReadable(response.body));
}
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
}
module.exports = proxyRoutes;

View File

@@ -0,0 +1,58 @@
const { Readable } = require('stream');
async function proxyRequest(url, { referer, origin, userAgent }) {
const headers = {
'User-Agent': userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9'
};
if (referer) headers['Referer'] = referer;
if (origin) headers['Origin'] = origin;
const response = await fetch(url, { headers, redirect: 'follow' });
if (!response.ok) {
throw new Error(`Proxy Error: ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
const isM3U8 = (contentType && contentType.includes('mpegurl')) || url.includes('.m3u8');
return {
response,
contentType,
isM3U8
};
}
function processM3U8Content(text, baseUrl, { referer, origin, userAgent }) {
return text.replace(/^(?!#)(?!\s*$).+/gm, (line) => {
line = line.trim();
let absoluteUrl;
try {
absoluteUrl = new URL(line, baseUrl).href;
} catch(e) {
return line;
}
const proxyParams = new URLSearchParams();
proxyParams.set('url', absoluteUrl);
if (referer) proxyParams.set('referer', referer);
if (origin) proxyParams.set('origin', origin);
if (userAgent) proxyParams.set('userAgent', userAgent);
return `/api/proxy?${proxyParams.toString()}`;
});
}
function streamToReadable(webStream) {
return Readable.fromWeb(webStream);
}
module.exports = {
proxyRequest,
processM3U8Content,
streamToReadable
};

37
src/views/views.routes.js Normal file
View File

@@ -0,0 +1,37 @@
const fs = require('fs');
const path = require('path');
async function viewsRoutes(fastify, options) {
fastify.get('/', (req, reply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'index.html'));
reply.type('text/html').send(stream);
});
fastify.get('/books', (req, reply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books.html'));
reply.type('text/html').send(stream);
});
fastify.get('/anime/:id', (req, reply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime.html'));
reply.type('text/html').send(stream);
});
fastify.get('/watch/:id/:episode', (req, reply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'watch.html'));
reply.type('text/html').send(stream);
});
fastify.get('/book/:id', (req, reply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'book.html'));
reply.type('text/html').send(stream);
});
fastify.get('/read/:id/:chapter/:provider', (req, reply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'read.html'));
reply.type('text/html').send(stream);
});
}
module.exports = viewsRoutes;