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 += ` +
+
${char.name.full} +
`; + }); + } + + 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 = ``; + } + + + 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 = '
Episode not found
'; + document.getElementById('pagination-controls').style.display = 'none'; + } +}); + +loadAnime(); \ No newline at end of file diff --git a/src/scripts/anime/animes.js b/src/scripts/anime/animes.js new file mode 100644 index 0000000..eccb71b --- /dev/null +++ b/src/scripts/anime/animes.js @@ -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 = '
No results found
'; + } 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 = ` + ${title} +
+
${title}
+
+ ${rating} + • ${year} + • ${format} +
+
+ `; + 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 = ` +
+
+

${title}

+

${score}% • ${ep}

+
+ `; + container.appendChild(el); + }); +} + +fetchContent(); +setInterval(() => fetchContent(true), 60000); \ No newline at end of file diff --git a/src/scripts/anime/player.js b/src/scripts/anime/player.js new file mode 100644 index 0000000..a053b54 --- /dev/null +++ b/src/scripts/anime/player.js @@ -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 = ''; + 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(); \ No newline at end of file diff --git a/public/book.js b/src/scripts/books/book.js similarity index 90% rename from public/book.js rename to src/scripts/books/book.js index 4599782..2c9264e 100644 --- a/public/book.js +++ b/src/scripts/books/book.js @@ -1,12 +1,11 @@ const bookId = window.location.pathname.split('/').pop(); -let allChapters = []; // Stores all fetched chapters -let filteredChapters = []; // Stores currently displayed chapters (filtered) +let allChapters = []; +let filteredChapters = []; let currentPage = 1; const itemsPerPage = 12; async function init() { try { - // 1. Load Metadata const res = await fetch(`/api/book/${bookId}`); const data = await res.json(); @@ -16,7 +15,6 @@ async function init() { return; } - // Populate Hero Elements const title = data.title.english || data.title.romaji; document.title = `${title} | WaifuBoard Books`; @@ -63,7 +61,6 @@ async function init() { const heroBgEl = document.getElementById('hero-bg'); if (heroBgEl) heroBgEl.src = data.bannerImage || img; - // 2. Load Chapters loadChapters(); } catch (err) { @@ -82,7 +79,7 @@ async function loadChapters() { const data = await res.json(); allChapters = data.chapters || []; - filteredChapters = [...allChapters]; // Initially, show all + filteredChapters = [...allChapters]; const totalEl = document.getElementById('total-chapters'); @@ -94,10 +91,8 @@ async function loadChapters() { if (totalEl) totalEl.innerText = `${allChapters.length} Found`; - // Populate Provider Filter populateProviderFilter(); - // Read Button Action (Start at filtered Ch 1) const readBtn = document.getElementById('read-start-btn'); if (readBtn && filteredChapters.length > 0) { readBtn.onclick = () => openReader(filteredChapters[0].id); @@ -115,14 +110,11 @@ function populateProviderFilter() { const select = document.getElementById('provider-filter'); if (!select) return; - // Extract unique providers const providers = [...new Set(allChapters.map(ch => ch.provider))]; - // Only show filter if there are actual providers found if (providers.length > 0) { select.style.display = 'inline-block'; - // Clear existing options except "All" select.innerHTML = ''; providers.forEach(prov => { @@ -132,7 +124,6 @@ function populateProviderFilter() { select.appendChild(opt); }); - // Attach Event Listener select.onchange = (e) => { const selected = e.target.value; if (selected === 'all') { @@ -140,7 +131,7 @@ function populateProviderFilter() { } else { filteredChapters = allChapters.filter(ch => ch.provider === selected); } - currentPage = 1; // Reset to page 1 on filter change + currentPage = 1; renderTable(); }; } @@ -208,7 +199,6 @@ function updatePagination() { } function openReader(bookId, chapterId, provider) { - localStorage.setItem('reader_prev_url', window.location.href); const c = encodeURIComponent(chapterId); const p = encodeURIComponent(provider); window.location.href = `/read/${bookId}/${c}/${p}`; diff --git a/public/books.js b/src/scripts/books/books.js similarity index 93% rename from public/books.js rename to src/scripts/books/books.js index 39c9a57..32bef62 100644 --- a/public/books.js +++ b/src/scripts/books/books.js @@ -2,14 +2,12 @@ let trendingBooks = []; let currentHeroIndex = 0; let heroInterval; -// --- NAVBAR SCROLL --- window.addEventListener('scroll', () => { const nav = document.getElementById('navbar'); if (window.scrollY > 50) nav.classList.add('scrolled'); else nav.classList.remove('scrolled'); }); -// --- SEARCH LOGIC --- const searchInput = document.getElementById('search-input'); const searchResults = document.getElementById('search-results'); let searchTimeout; @@ -25,13 +23,11 @@ searchInput.addEventListener('input', (e) => { return; } - // Debounce 300ms searchTimeout = setTimeout(() => { fetchBookSearch(query); }, 300); }); -// Hide results on outside click document.addEventListener('click', (e) => { if (!e.target.closest('.search-wrapper')) { searchResults.classList.remove('active'); @@ -65,7 +61,7 @@ function renderSearchResults(results) { const item = document.createElement('a'); item.className = 'search-item'; - item.href = `/book/${book.id}`; // Direct navigation link + item.href = `/book/${book.id}`; item.innerHTML = ` ${title} @@ -87,7 +83,6 @@ function renderSearchResults(results) { searchInput.style.borderRadius = '12px 12px 0 0'; } -// --- CAROUSEL LOGIC --- function scrollCarousel(id, direction) { const container = document.getElementById(id); if(container) { @@ -96,10 +91,8 @@ function scrollCarousel(id, direction) { } } -// --- FETCH DATA --- async function init() { try { - // Fetch Trending const res = await fetch('/api/books/trending'); const data = await res.json(); @@ -110,7 +103,6 @@ async function init() { startHeroCycle(); } - // Fetch Popular const resPop = await fetch('/api/books/popular'); const dataPop = await resPop.json(); if (dataPop.results) renderList('popular', dataPop.results); @@ -120,7 +112,6 @@ async function init() { } } -// --- HERO LOGIC --- function startHeroCycle() { if(heroInterval) clearInterval(heroInterval); heroInterval = setInterval(() => { @@ -147,18 +138,15 @@ function updateHeroUI(book) { const heroPoster = document.getElementById('hero-poster'); if(heroPoster) heroPoster.src = poster; - // Update background const bg = document.getElementById('hero-bg-media'); if(bg) bg.src = banner; - // Setup Read Now Button const readBtn = document.getElementById('read-btn'); if (readBtn) { readBtn.onclick = () => window.location.href = `/book/${book.id}`; } } -// --- RENDER LIST --- function renderList(id, list) { const container = document.getElementById(id); container.innerHTML = ''; @@ -172,7 +160,6 @@ function renderList(id, list) { const el = document.createElement('div'); el.className = 'card'; el.onclick = () => { - // Navigate to book page window.location.href = `/book/${book.id}`; }; el.innerHTML = ` diff --git a/public/reader.js b/src/scripts/books/reader.js similarity index 97% rename from public/reader.js rename to src/scripts/books/reader.js index d3e01e8..ea62049 100644 --- a/public/reader.js +++ b/src/scripts/books/reader.js @@ -7,8 +7,6 @@ const chapterLabel = document.getElementById('chapter-label'); const prevBtn = document.getElementById('prev-chapter'); const nextBtn = document.getElementById('next-chapter'); -const prevUrl = localStorage.getItem('reader_prev_url'); - const lnSettings = document.getElementById('ln-settings'); const mangaSettings = document.getElementById('manga-settings'); @@ -64,7 +62,6 @@ function saveConfig() { } function updateUIFromConfig() { - // Light Novel document.getElementById('font-size').value = config.ln.fontSize; document.getElementById('font-size-value').textContent = config.ln.fontSize + 'px'; @@ -78,19 +75,16 @@ function updateUIFromConfig() { document.getElementById('text-color').value = config.ln.textColor; document.getElementById('bg-color').value = config.ln.bg; - // Text alignment buttons document.querySelectorAll('[data-align]').forEach(btn => { btn.classList.toggle('active', btn.dataset.align === config.ln.textAlign); }); - // Manga 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; - // Direction buttons document.querySelectorAll('[data-direction]').forEach(btn => { btn.classList.toggle('active', btn.dataset.direction === config.manga.direction); }); @@ -349,7 +343,6 @@ function loadLN(html) { reader.appendChild(div); } -// Event Listeners - Light Novel document.getElementById('font-size').addEventListener('input', (e) => { config.ln.fontSize = parseInt(e.target.value); document.getElementById('font-size-value').textContent = e.target.value + 'px'; @@ -389,7 +382,6 @@ document.getElementById('bg-color').addEventListener('change', (e) => { saveConfig(); }); -// Text alignment document.querySelectorAll('[data-align]').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('[data-align]').forEach(b => b.classList.remove('active')); @@ -400,7 +392,6 @@ document.querySelectorAll('[data-align]').forEach(btn => { }); }); -// Presets document.querySelectorAll('[data-preset]').forEach(btn => { btn.addEventListener('click', () => { const preset = btn.dataset.preset; @@ -422,8 +413,6 @@ document.querySelectorAll('[data-preset]').forEach(btn => { }); }); -// Event Listeners - Manga - document.getElementById('display-mode').addEventListener('change', (e) => { config.manga.mode = e.target.value; saveConfig(); @@ -448,7 +437,6 @@ document.getElementById('preload-count').addEventListener('change', (e) => { saveConfig(); }); -// Direction document.querySelectorAll('[data-direction]').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('[data-direction]').forEach(b => b.classList.remove('active')); @@ -459,7 +447,6 @@ document.querySelectorAll('[data-direction]').forEach(btn => { }); }); -// Navigation prevBtn.addEventListener('click', () => { const newChapter = String(parseInt(chapter) - 1); updateURL(newChapter); @@ -481,12 +468,10 @@ function updateURL(newChapter) { } document.getElementById('back-btn').addEventListener('click', () => { - const prev = localStorage.getItem('reader_prev_url'); - if (prev) { - window.location.href = prev; - } else { - history.back(); - } + const parts = window.location.pathname.split('/'); + + const mangaId = parts[2]; + window.location.href = `/book/${mangaId}`; }); settingsBtn.addEventListener('click', () => { diff --git a/src/shared/database.js b/src/shared/database.js new file mode 100644 index 0000000..89047d8 --- /dev/null +++ b/src/shared/database.js @@ -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 +}; \ No newline at end of file diff --git a/src/shared/extensions.js b/src/shared/extensions.js new file mode 100644 index 0000000..7ced629 --- /dev/null +++ b/src/shared/extensions.js @@ -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 +}; \ No newline at end of file diff --git a/src/shared/proxy/proxy.routes.js b/src/shared/proxy/proxy.routes.js new file mode 100644 index 0000000..2a4d434 --- /dev/null +++ b/src/shared/proxy/proxy.routes.js @@ -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; \ No newline at end of file diff --git a/src/shared/proxy/proxy.service.js b/src/shared/proxy/proxy.service.js new file mode 100644 index 0000000..7b1e1da --- /dev/null +++ b/src/shared/proxy/proxy.service.js @@ -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 +}; \ No newline at end of file diff --git a/src/views/views.routes.js b/src/views/views.routes.js new file mode 100644 index 0000000..96588ba --- /dev/null +++ b/src/views/views.routes.js @@ -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; \ No newline at end of file diff --git a/views/anime.html b/views/anime.html index 28f22fb..0e464c4 100644 --- a/views/anime.html +++ b/views/anime.html @@ -3,484 +3,130 @@ - + WaifuBoard - - + - -