const fastify = require('fastify')({ logger: true }); const path = require('path'); const fs = require('fs'); const os = require('os'); const { animeMetadata } = require('./src/metadata/anilist'); const sqlite3 = require('sqlite3').verbose(); // --- DATABASE CONNECTION --- const DB_PATH = path.join(__dirname, 'src', 'metadata', 'anilist_anime.db'); const 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."); }); // --- EXTENSION LOADER --- const extensions = new Map(); async function loadExtensions() { const homeDir = os.homedir(); const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions'); if (!fs.existsSync(extensionsDir)) { 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); } } } } catch (err) { console.error("Extension Scan Error:", err); } } loadExtensions(); // --- STATIC & VIEWS --- fastify.register(require('@fastify/static'), { root: path.join(__dirname, 'public'), prefix: '/public/', }); fastify.get('/', (req, reply) => { const stream = fs.createReadStream(path.join(__dirname, 'views', 'index.html')); reply.type('text/html').send(stream); }); // NEW: Books Page 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); }); // --- API ENDPOINTS --- // NEW: Books API (Manga) fastify.get('/api/books/trending', (req, reply) => { return new Promise((resolve) => { db.all("SELECT full_data FROM trending_books ORDER BY rank ASC LIMIT 10", [], (err, rows) => { if (err || !rows) resolve({ results: [] }); else resolve({ results: rows.map(r => JSON.parse(r.full_data)) }); }); }); }); fastify.get('/api/books/popular', (req, reply) => { return new Promise((resolve) => { db.all("SELECT full_data FROM popular_books ORDER BY rank ASC LIMIT 10", [], (err, rows) => { if (err || !rows) resolve({ results: [] }); else resolve({ results: rows.map(r => JSON.parse(r.full_data)) }); }); }); }); // ... [Keep previous Anime/Proxy APIs] ... // 1. Proxy fastify.get('/api/proxy', async (req, reply) => { const { url, referer, origin, userAgent } = req.query; if (!url) return reply.code(400).send("No URL provided"); 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; try { const response = await fetch(url, { headers, redirect: 'follow' }); if (!response.ok) return reply.code(response.status).send(`Proxy Error: ${response.statusText}`); reply.header('Access-Control-Allow-Origin', '*'); reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); const contentType = response.headers.get('content-type'); if (contentType) reply.header('Content-Type', contentType); const isM3U8 = (contentType && (contentType.includes('mpegurl'))) || url.includes('.m3u8'); if (isM3U8) { const text = await response.text(); const baseUrl = new URL(response.url); const newText = 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()}`; }); return newText; } else { const { Readable } = require('stream'); return reply.send(Readable.fromWeb(response.body)); } } catch (err) { fastify.log.error(err); return reply.code(500).send("Internal Server Error"); } }); // Extensions fastify.get('/api/extensions', async (req, reply) => { return { extensions: Array.from(extensions.keys()) }; }); fastify.get('/api/extension/:name/settings', async (req, reply) => { const name = req.params.name; const ext = extensions.get(name); if (!ext) return { error: "Extension not found" }; if (!ext.getSettings) return { episodeServers: ["default"], supportsDub: false }; return ext.getSettings(); }); fastify.get('/api/watch/stream', async (req, reply) => { const { animeId, episode, server, category, ext } = req.query; const extension = extensions.get(ext); if (!extension) return { error: "Extension not found" }; const animeData = await new Promise((resolve) => { db.get("SELECT full_data FROM anime WHERE id = ?", [animeId], (err, row) => { if (err || !row) resolve(null); else resolve(JSON.parse(row.full_data)); }); }); if (!animeData) return { error: "Anime metadata not found" }; try { 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) return { 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) return { error: "Episode not found" }; const serverName = server || "default"; const streamData = await extension.findEpisodeServer(targetEp, serverName); return streamData; } catch (err) { return { error: err.message }; } }); fastify.get('/api/anime/:id', (req, reply) => { const id = req.params.id; return new Promise((resolve) => { db.get("SELECT full_data FROM anime WHERE id = ?", [id], (err, row) => { if(err) resolve({ error: "Database error" }); else if (!row) resolve({ error: "Anime not found" }); else resolve(JSON.parse(row.full_data)); }); }); }); fastify.get('/api/search/books', async (req, reply) => { const query = req.query.q; if (!query || query.length < 2) return { results: [] }; // A. Local DB Search (Prioritized) const dbResults = await new Promise((resolve) => { const sql = `SELECT full_data FROM books WHERE full_data LIKE ? LIMIT 50`; db.all(sql, [`%${query}%`], (err, rows) => { if (err || !rows) resolve([]); else { try { 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())); }); resolve(clean.slice(0, 10)); } catch (e) { resolve([]); } } }); }); if (dbResults.length > 0) { return { results: dbResults }; } // B. Live AniList Fallback (If Local DB is empty/missing data) try { console.log(`[Books] Local DB miss for "${query}", fetching live...`); 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 { results: liveData.data.Page.media }; } } catch(e) { console.error("Live Search Error:", e.message); } // C. Extensions Fallback (If not on AniList at all) let extResults = []; for (const [name, ext] of extensions) { // UPDATED: Check for 'book-board' or 'manga-board' 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) { extResults = matches.map(m => ({ id: m.id, title: { romaji: m.title, english: m.title }, coverImage: { large: m.image || '' }, // UPDATED: Try to get score from extension if available averageScore: m.rating || m.score || null, format: 'MANGA', seasonYear: null, isExtensionResult: true })); break; } } catch (e) { console.error(`Extension search failed for ${name}:`, e); } } } return { results: extResults }; }); fastify.get('/api/book/:id/chapters', async (req, reply) => { const id = req.params.id; // Helper to get metadata (Local or Live) let bookData = await new Promise((resolve) => { db.get("SELECT full_data FROM books WHERE id = ?", [id], (err, row) => { if (err || !row) resolve(null); else resolve(JSON.parse(row.full_data)); }); }); 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]; // Prefer English, fallback to Romaji const allChapters = []; // Create an array of promises for all matching extensions 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}`); // Pass strict search options const matches = await ext.search({ query: searchTitle, media: { romajiTitle: bookData.title.romaji, englishTitle: bookData.title.english, startDate: bookData.startDate } }); if (matches && matches.length > 0) { // Use the first match to find chapters 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); // Add to aggregator with provider tag 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); } }); // Wait for all providers to finish (in parallel) await Promise.all(searchPromises); // Sort all aggregated chapters by number const sortedChapters = allChapters.sort((a, b) => a.number - b.number); return { chapters: sortedChapters }; }); fastify.get('/api/book/:id', async (req, reply) => { const id = req.params.id; // 1. Try Local DB const bookData = await new Promise((resolve) => { db.get("SELECT full_data FROM books WHERE id = ?", [id], (err, row) => { if(err || !row) resolve(null); else resolve(JSON.parse(row.full_data)); }); }); if (bookData) return bookData; // 2. Live Fallback (If not in DB) 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 } } }`; // CRITICAL FIX: Ensure ID is parsed as Integer for AniList GraphQL 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; } return { error: "Book not found on AniList" }; } catch(e) { fastify.log.error(e); return { error: "Fetch error" }; } }); fastify.get('/book/:id', (req, reply) => { const stream = fs.createReadStream(path.join(__dirname, 'views', 'book.html')); reply.type('text/html').send(stream); }); fastify.get('/api/search/local', (req, reply) => { const query = req.query.q; if (!query || query.length < 2) return { results: [] }; // Increased limit to 50 here as well for consistency const sql = `SELECT full_data FROM anime WHERE full_data LIKE ? LIMIT 50`; return new Promise((resolve) => { db.all(sql, [`%${query}%`], (err, rows) => { if (err) resolve({ results: [] }); else { try { 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)); }); resolve({ results: cleanResults.slice(0, 10) }); } catch (e) { resolve({ results: [] }); } } }); }); }); fastify.get('/api/trending', (req, reply) => { return new Promise((resolve) => db.all("SELECT full_data FROM trending ORDER BY rank ASC LIMIT 10", [], (err, rows) => resolve({ results: rows ? rows.map(r => JSON.parse(r.full_data)) : [] }))); }); fastify.get('/api/top-airing', (req, reply) => { return new Promise((resolve) => db.all("SELECT full_data FROM top_airing ORDER BY rank ASC LIMIT 10", [], (err, rows) => resolve({ results: rows ? rows.map(r => JSON.parse(r.full_data)) : [] }))); }); const start = async () => { try { await fastify.listen({ port: 3000, host: '0.0.0.0' }); console.log(`Server running at http://localhost:3000`); animeMetadata(); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();