579 lines
22 KiB
JavaScript
579 lines
22 KiB
JavaScript
const fastify = require('fastify')({ logger: true });
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const { animeMetadata } = require('./src/metadata/anilist');
|
|
const sqlite3 = require('sqlite3').verbose();
|
|
|
|
// --- DATABASE CONNECTION ---
|
|
const DB_PATH = path.join(__dirname, 'src', 'metadata', 'anilist_anime.db');
|
|
const db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READONLY, (err) => {
|
|
if (err) console.error("Database Error:", err.message);
|
|
else console.log("Connected to local AniList database.");
|
|
});
|
|
|
|
// --- EXTENSION LOADER ---
|
|
const extensions = new Map();
|
|
|
|
async function loadExtensions() {
|
|
const homeDir = os.homedir();
|
|
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
|
|
|
|
if (!fs.existsSync(extensionsDir)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const files = await fs.promises.readdir(extensionsDir);
|
|
for (const file of files) {
|
|
if (file.endsWith('.js')) {
|
|
const filePath = path.join(extensionsDir, file);
|
|
try {
|
|
delete require.cache[require.resolve(filePath)];
|
|
const ExtensionClass = require(filePath);
|
|
const instance = typeof ExtensionClass === 'function'
|
|
? new ExtensionClass()
|
|
: (ExtensionClass.default ? new ExtensionClass.default() : null);
|
|
|
|
if (instance && (instance.type === "anime-board" || instance.type === "book-board")) {
|
|
const name = instance.constructor.name;
|
|
extensions.set(name, instance);
|
|
console.log(`Loaded Extension: ${name}`);
|
|
}
|
|
} catch (e) {
|
|
console.error(`Failed to load extension ${file}:`, e);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("Extension Scan Error:", err);
|
|
}
|
|
}
|
|
|
|
loadExtensions();
|
|
|
|
// --- STATIC & VIEWS ---
|
|
fastify.register(require('@fastify/static'), {
|
|
root: path.join(__dirname, 'public'),
|
|
prefix: '/public/',
|
|
});
|
|
|
|
fastify.get('/', (req, reply) => {
|
|
const stream = fs.createReadStream(path.join(__dirname, 'views', 'index.html'));
|
|
reply.type('text/html').send(stream);
|
|
});
|
|
|
|
// NEW: Books Page
|
|
fastify.get('/books', (req, reply) => {
|
|
const stream = fs.createReadStream(path.join(__dirname, 'views', 'books.html'));
|
|
reply.type('text/html').send(stream);
|
|
});
|
|
|
|
fastify.get('/anime/:id', (req, reply) => {
|
|
const stream = fs.createReadStream(path.join(__dirname, 'views', 'anime.html'));
|
|
reply.type('text/html').send(stream);
|
|
});
|
|
|
|
fastify.get('/watch/:id/:episode', (req, reply) => {
|
|
const stream = fs.createReadStream(path.join(__dirname, 'views', 'watch.html'));
|
|
reply.type('text/html').send(stream);
|
|
});
|
|
|
|
// --- API ENDPOINTS ---
|
|
|
|
// NEW: Books API (Manga)
|
|
fastify.get('/api/books/trending', (req, reply) => {
|
|
return new Promise((resolve) => {
|
|
db.all("SELECT full_data FROM trending_books ORDER BY rank ASC LIMIT 10", [], (err, rows) => {
|
|
if (err || !rows) resolve({ results: [] });
|
|
else resolve({ results: rows.map(r => JSON.parse(r.full_data)) });
|
|
});
|
|
});
|
|
});
|
|
|
|
fastify.get('/api/books/popular', (req, reply) => {
|
|
return new Promise((resolve) => {
|
|
db.all("SELECT full_data FROM popular_books ORDER BY rank ASC LIMIT 10", [], (err, rows) => {
|
|
if (err || !rows) resolve({ results: [] });
|
|
else resolve({ results: rows.map(r => JSON.parse(r.full_data)) });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ... [Keep previous Anime/Proxy APIs] ...
|
|
// 1. Proxy
|
|
fastify.get('/api/proxy', async (req, reply) => {
|
|
const { url, referer, origin, userAgent } = req.query;
|
|
if (!url) return reply.code(400).send("No URL provided");
|
|
|
|
const headers = {
|
|
'User-Agent': userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
'Accept': '*/*',
|
|
'Accept-Language': 'en-US,en;q=0.9'
|
|
};
|
|
if (referer) headers['Referer'] = referer;
|
|
if (origin) headers['Origin'] = origin;
|
|
|
|
try {
|
|
const response = await fetch(url, { headers, redirect: 'follow' });
|
|
if (!response.ok) return reply.code(response.status).send(`Proxy Error: ${response.statusText}`);
|
|
|
|
reply.header('Access-Control-Allow-Origin', '*');
|
|
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType) reply.header('Content-Type', contentType);
|
|
|
|
const isM3U8 = (contentType && (contentType.includes('mpegurl'))) || url.includes('.m3u8');
|
|
|
|
if (isM3U8) {
|
|
const text = await response.text();
|
|
const baseUrl = new URL(response.url);
|
|
const newText = text.replace(/^(?!#)(?!\s*$).+/gm, (line) => {
|
|
line = line.trim();
|
|
let absoluteUrl;
|
|
try { absoluteUrl = new URL(line, baseUrl).href; } catch(e) { return line; }
|
|
const proxyParams = new URLSearchParams();
|
|
proxyParams.set('url', absoluteUrl);
|
|
if (referer) proxyParams.set('referer', referer);
|
|
if (origin) proxyParams.set('origin', origin);
|
|
if (userAgent) proxyParams.set('userAgent', userAgent);
|
|
return `/api/proxy?${proxyParams.toString()}`;
|
|
});
|
|
return newText;
|
|
} else {
|
|
const { Readable } = require('stream');
|
|
return reply.send(Readable.fromWeb(response.body));
|
|
}
|
|
} catch (err) {
|
|
fastify.log.error(err);
|
|
return reply.code(500).send("Internal Server Error");
|
|
}
|
|
});
|
|
|
|
// Extensions
|
|
fastify.get('/api/extensions', async (req, reply) => {
|
|
return { extensions: Array.from(extensions.keys()) };
|
|
});
|
|
|
|
fastify.get('/api/extension/:name/settings', async (req, reply) => {
|
|
const name = req.params.name;
|
|
const ext = extensions.get(name);
|
|
if (!ext) return { error: "Extension not found" };
|
|
if (!ext.getSettings) return { episodeServers: ["default"], supportsDub: false };
|
|
return ext.getSettings();
|
|
});
|
|
|
|
fastify.get('/api/watch/stream', async (req, reply) => {
|
|
const { animeId, episode, server, category, ext } = req.query;
|
|
const extension = extensions.get(ext);
|
|
if (!extension) return { error: "Extension not found" };
|
|
|
|
const animeData = await new Promise((resolve) => {
|
|
db.get("SELECT full_data FROM anime WHERE id = ?", [animeId], (err, row) => {
|
|
if (err || !row) resolve(null);
|
|
else resolve(JSON.parse(row.full_data));
|
|
});
|
|
});
|
|
|
|
if (!animeData) return { error: "Anime metadata not found" };
|
|
|
|
try {
|
|
const searchOptions = {
|
|
query: animeData.title.english || animeData.title.romaji,
|
|
dub: category === 'dub',
|
|
media: {
|
|
romajiTitle: animeData.title.romaji,
|
|
englishTitle: animeData.title.english || "",
|
|
startDate: animeData.startDate || { year: 0, month: 0, day: 0 }
|
|
}
|
|
};
|
|
|
|
const searchResults = await extension.search(searchOptions);
|
|
if (!searchResults || searchResults.length === 0) return { error: "Anime not found on provider" };
|
|
|
|
const bestMatch = searchResults[0];
|
|
const episodes = await extension.findEpisodes(bestMatch.id);
|
|
const targetEp = episodes.find(e => e.number === parseInt(episode));
|
|
|
|
if (!targetEp) return { error: "Episode not found" };
|
|
|
|
const serverName = server || "default";
|
|
const streamData = await extension.findEpisodeServer(targetEp, serverName);
|
|
return streamData;
|
|
} catch (err) {
|
|
return { error: err.message };
|
|
}
|
|
});
|
|
|
|
fastify.get('/api/anime/:id', (req, reply) => {
|
|
const id = req.params.id;
|
|
return new Promise((resolve) => {
|
|
db.get("SELECT full_data FROM anime WHERE id = ?", [id], (err, row) => {
|
|
if(err) resolve({ error: "Database error" });
|
|
else if (!row) resolve({ error: "Anime not found" });
|
|
else resolve(JSON.parse(row.full_data));
|
|
});
|
|
});
|
|
});
|
|
|
|
fastify.get('/api/search/books', async (req, reply) => {
|
|
const query = req.query.q;
|
|
if (!query || query.length < 2) return { results: [] };
|
|
|
|
// A. Local DB Search (Prioritized)
|
|
const dbResults = await new Promise((resolve) => {
|
|
const sql = `SELECT full_data FROM books WHERE full_data LIKE ? LIMIT 50`;
|
|
db.all(sql, [`%${query}%`], (err, rows) => {
|
|
if (err || !rows) resolve([]);
|
|
else {
|
|
try {
|
|
const results = rows.map(row => JSON.parse(row.full_data));
|
|
const clean = results.filter(book => {
|
|
const searchTerms = [
|
|
book.title.english,
|
|
book.title.romaji,
|
|
book.title.native,
|
|
...(book.synonyms || [])
|
|
].filter(Boolean).map(t => t.toLowerCase());
|
|
return searchTerms.some(term => term.includes(query.toLowerCase()));
|
|
});
|
|
resolve(clean.slice(0, 10));
|
|
} catch (e) { resolve([]); }
|
|
}
|
|
});
|
|
});
|
|
|
|
if (dbResults.length > 0) {
|
|
return { results: dbResults };
|
|
}
|
|
|
|
// B. Live AniList Fallback (If Local DB is empty/missing data)
|
|
try {
|
|
console.log(`[Books] Local DB miss for "${query}", fetching live...`);
|
|
const gql = `
|
|
query ($search: String) {
|
|
Page(page: 1, perPage: 5) {
|
|
media(search: $search, type: MANGA, isAdult: false) {
|
|
id title { romaji english native }
|
|
coverImage { extraLarge large }
|
|
bannerImage description averageScore format
|
|
seasonYear startDate { year }
|
|
}
|
|
}
|
|
}`;
|
|
|
|
const response = await fetch('https://graphql.anilist.co', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: JSON.stringify({ query: gql, variables: { search: query } })
|
|
});
|
|
|
|
const liveData = await response.json();
|
|
if (liveData.data && liveData.data.Page.media.length > 0) {
|
|
return { results: liveData.data.Page.media };
|
|
}
|
|
} catch(e) {
|
|
console.error("Live Search Error:", e.message);
|
|
}
|
|
|
|
// C. Extensions Fallback (If not on AniList at all)
|
|
let extResults = [];
|
|
for (const [name, ext] of extensions) {
|
|
// UPDATED: Check for 'book-board' or 'manga-board'
|
|
if ((ext.type === 'book-board' || ext.type === 'manga-board') && ext.search) {
|
|
try {
|
|
console.log(`[${name}] Searching for book: ${query}`);
|
|
const matches = await ext.search({
|
|
query: query,
|
|
media: {
|
|
romajiTitle: query,
|
|
englishTitle: query,
|
|
startDate: { year: 0, month: 0, day: 0 }
|
|
}
|
|
});
|
|
|
|
if (matches && matches.length > 0) {
|
|
extResults = matches.map(m => ({
|
|
id: m.id,
|
|
title: { romaji: m.title, english: m.title },
|
|
coverImage: { large: m.image || '' },
|
|
// UPDATED: Try to get score from extension if available
|
|
averageScore: m.rating || m.score || null,
|
|
format: 'MANGA',
|
|
seasonYear: null,
|
|
isExtensionResult: true
|
|
}));
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
console.error(`Extension search failed for ${name}:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { results: extResults };
|
|
});
|
|
|
|
fastify.get('/api/book/:id/chapters', async (req, reply) => {
|
|
const id = req.params.id;
|
|
|
|
// Helper to get metadata (Local or Live)
|
|
let bookData = await new Promise((resolve) => {
|
|
db.get("SELECT full_data FROM books WHERE id = ?", [id], (err, row) => {
|
|
if (err || !row) resolve(null);
|
|
else resolve(JSON.parse(row.full_data));
|
|
});
|
|
});
|
|
|
|
if (!bookData) {
|
|
try {
|
|
const query = `query ($id: Int) { Media(id: $id, type: MANGA) { title { romaji english } startDate { year month day } } }`;
|
|
const res = await fetch('https://graphql.anilist.co', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query, variables: { id: parseInt(id) } })
|
|
});
|
|
const d = await res.json();
|
|
if(d.data?.Media) bookData = d.data.Media;
|
|
} catch(e) {}
|
|
}
|
|
|
|
if (!bookData) return { chapters: [] };
|
|
|
|
const titles = [bookData.title.english, bookData.title.romaji].filter(t => t);
|
|
const searchTitle = titles[0]; // Prefer English, fallback to Romaji
|
|
|
|
const allChapters = [];
|
|
|
|
// Create an array of promises for all matching extensions
|
|
const searchPromises = Array.from(extensions.entries())
|
|
.filter(([name, ext]) => (ext.type === 'book-board' || ext.type === 'manga-board') && ext.search && ext.findChapters)
|
|
.map(async ([name, ext]) => {
|
|
try {
|
|
console.log(`[${name}] Searching chapters for: ${searchTitle}`);
|
|
|
|
// Pass strict search options
|
|
const matches = await ext.search({
|
|
query: searchTitle,
|
|
media: {
|
|
romajiTitle: bookData.title.romaji,
|
|
englishTitle: bookData.title.english,
|
|
startDate: bookData.startDate
|
|
}
|
|
});
|
|
|
|
if (matches && matches.length > 0) {
|
|
// Use the first match to find chapters
|
|
const best = matches[0];
|
|
const chaps = await ext.findChapters(best.id);
|
|
|
|
if (chaps && chaps.length > 0) {
|
|
console.log(`[${name}] Found ${chaps.length} chapters.`);
|
|
chaps.forEach(ch => {
|
|
const num = parseFloat(ch.number);
|
|
// Add to aggregator with provider tag
|
|
allChapters.push({
|
|
id: ch.id,
|
|
number: num,
|
|
title: ch.title,
|
|
date: ch.releaseDate,
|
|
provider: name
|
|
});
|
|
});
|
|
}
|
|
} else {
|
|
console.log(`[${name}] No matches found for book.`);
|
|
}
|
|
} catch (e) {
|
|
console.error(`Failed to fetch chapters from ${name}:`, e.message);
|
|
}
|
|
});
|
|
|
|
// Wait for all providers to finish (in parallel)
|
|
await Promise.all(searchPromises);
|
|
|
|
// Sort all aggregated chapters by number
|
|
const sortedChapters = allChapters.sort((a, b) => a.number - b.number);
|
|
|
|
return { chapters: sortedChapters };
|
|
});
|
|
|
|
fastify.get('/api/book/: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);
|
|
});
|
|
|
|
const start = async () => {
|
|
try {
|
|
await fastify.listen({ port: 3000, host: '0.0.0.0' });
|
|
console.log(`Server running at http://localhost:3000`);
|
|
animeMetadata();
|
|
} catch (err) {
|
|
fastify.log.error(err);
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
start(); |