diff --git a/README.md b/README.md index ce212cd..4ff6ad5 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The official recode repo, its private no one should know about this or have the | -----|------| ------ | | Book Reader | ✅ | N/A | | Multi book provider loading | ✅ | N/A | -| Better Code Organization | Not Done | N/A | +| Better Code Organization | ✅ | N/A | | Mobile View | Not Done | N/A | | Gallery | Not Done | N/A | | Anime Schedule (Release Calendar for the month) | Not Done | N/A | diff --git a/public/waifuboards.ico b/public/assets/waifuboards.ico similarity index 100% rename from public/waifuboards.ico rename to public/assets/waifuboards.ico diff --git a/server.js b/server.js index cfa7225..cf43e1a 100644 --- a/server.js +++ b/server.js @@ -1,574 +1,46 @@ 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."); -}); +const { initDatabase } = require('./src/shared/database'); +const { loadExtensions } = require('./src/shared/extensions'); -// --- EXTENSION LOADER --- -const extensions = new Map(); +const viewsRoutes = require('./src/views/views.routes'); +const animeRoutes = require('./src/anime/anime.routes'); +const booksRoutes = require('./src/books/books.routes'); +const proxyRoutes = require('./src/shared/proxy/proxy.routes'); -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/', + decorateReply: false }); -fastify.get('/', (req, reply) => { - const stream = fs.createReadStream(path.join(__dirname, 'views', 'index.html')); - reply.type('text/html').send(stream); +fastify.register(require('@fastify/static'), { + root: path.join(__dirname, 'views'), + prefix: '/views/', + decorateReply: false }); -// 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.register(require('@fastify/static'), { + root: path.join(__dirname, 'src'), + prefix: '/src/', + decorateReply: false }); -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/:bookId/:chapter/:provider', async (req, reply) => { - const { bookId, chapter, provider } = req.params; - - const ext = extensions.get(provider); - if (!ext) - return reply.code(404).send({ error: "Provider not found" }); - - let chapterId = decodeURIComponent(chapter); - let chapterTitle = null; - let chapterNumber = null; - - const index = parseInt(chapter); - const chapterList = await fetch( - `http://localhost:3000/api/book/${bookId}/chapters` - ).then(r => r.json()); - - if (!chapterList?.chapters) - return reply.code(404).send({ error: "Chapters not found" }); - - const providerChapters = chapterList.chapters.filter( - c => c.provider === provider - ); - - if (!providerChapters[index]) - return reply.code(404).send({ error: "Chapter index out of range" }); - - const selected = providerChapters[index]; - - chapterId = selected.id; - chapterTitle = selected.title || null; - chapterNumber = selected.number || index; - - - try { - if (ext.mediaType === "manga") { - const pages = await ext.findChapterPages(chapterId); - return reply.send({ - type: "manga", - chapterId, - title: chapterTitle, - number: chapterNumber, - provider, - pages - }); - } - - if (ext.mediaType === "ln") { - const content = await ext.findChapterPages(chapterId); - return reply.send({ - type: "ln", - chapterId, - title: chapterTitle, - number: chapterNumber, - provider, - content - }); - } - - return reply.code(400).send({ error: "Unknown mediaType" }); - - } catch (err) { - console.error(err); - return reply.code(500).send({ error: "Error loading chapter" }); - } -}); - - -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)) : [] }))); -}); - -fastify.get('/read/:id/:chapter/:provider', (req, reply) => { - const stream = fs.createReadStream(path.join(__dirname, 'views', 'reader.html')); - reply.type('text/html').send(stream); -}); +fastify.register(viewsRoutes); +fastify.register(animeRoutes, { prefix: '/api' }); +fastify.register(booksRoutes, { prefix: '/api' }); +fastify.register(proxyRoutes, { prefix: '/api' }); const start = async () => { try { + initDatabase(); + await loadExtensions(); + 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); diff --git a/src/anime/anime.controller.js b/src/anime/anime.controller.js new file mode 100644 index 0000000..aa969c1 --- /dev/null +++ b/src/anime/anime.controller.js @@ -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 +}; \ No newline at end of file diff --git a/src/anime/anime.routes.js b/src/anime/anime.routes.js new file mode 100644 index 0000000..9439543 --- /dev/null +++ b/src/anime/anime.routes.js @@ -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; \ No newline at end of file diff --git a/src/anime/anime.service.js b/src/anime/anime.service.js new file mode 100644 index 0000000..29e4457 --- /dev/null +++ b/src/anime/anime.service.js @@ -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 +}; \ No newline at end of file diff --git a/src/books/books.controller.js b/src/books/books.controller.js new file mode 100644 index 0000000..c7faff1 --- /dev/null +++ b/src/books/books.controller.js @@ -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 +}; \ No newline at end of file diff --git a/src/books/books.routes.js b/src/books/books.routes.js new file mode 100644 index 0000000..72637ac --- /dev/null +++ b/src/books/books.routes.js @@ -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; \ No newline at end of file diff --git a/src/books/books.service.js b/src/books/books.service.js new file mode 100644 index 0000000..662c5ca --- /dev/null +++ b/src/books/books.service.js @@ -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 +}; \ No newline at end of file diff --git a/src/metadata/anilist.js b/src/metadata/anilist.js index 44ab6ed..166b8b7 100644 --- a/src/metadata/anilist.js +++ b/src/metadata/anilist.js @@ -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'); } diff --git a/src/scripts/anime/anime.js b/src/scripts/anime/anime.js new file mode 100644 index 0000000..29878ff --- /dev/null +++ b/src/scripts/anime/anime.js @@ -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 += ` +
${score}% • ${ep}
+