Files
WaifuBoard/src/books/books.service.ts
2025-11-29 17:41:30 +01:00

312 lines
11 KiB
TypeScript

import { queryOne, queryAll } from '../shared/database';
import { getAllExtensions } from '../shared/extensions';
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
export async function getBookById(id: string | number): Promise<Book | { error: string }> {
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.toString()) } })
});
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" };
}
export async function getTrendingBooks(): Promise<Book[]> {
const rows = await queryAll("SELECT full_data FROM trending_books ORDER BY rank ASC LIMIT 10");
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
}
export async function getPopularBooks(): Promise<Book[]> {
const rows = await queryAll("SELECT full_data FROM popular_books ORDER BY rank ASC LIMIT 10");
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
}
export async function searchBooksLocal(query: string): Promise<Book[]> {
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: Book[] = rows.map((row: { full_data: string; }) => 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);
}
export async function searchBooksAniList(query: string): Promise<Book[]> {
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 [];
}
export async function searchBooksInExtension(ext: Extension | null, name: string, query: string): Promise<Book[]> {
if (!ext) return [];
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,
extensionName: name,
title: { romaji: m.title, english: m.title, native: null },
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 [];
}
export async function searchBooksExtensions(query: string): Promise<Book[]> {
const extensions = getAllExtensions();
for (const [name, ext] of extensions) {
const results = await searchBooksInExtension(ext, name, query);
if (results.length > 0) return results;
}
return [];
}
export async function getChaptersForBook(id: string): Promise<{ chapters: ChapterWithProvider[] }> {
let bookData: Book | null = null;
let searchTitle: string | null = null;
if (typeof id === "string" && isNaN(Number(id))) {
searchTitle = id.replaceAll("-", " ");
} else {
const result = await getBookById(id);
if (!('error' in result)) {
bookData = result;
}
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(Boolean) as string[];
searchTitle = titles[0];
}
const allChapters: ChapterWithProvider[] = [];
const extensions = getAllExtensions();
const searchPromises = Array.from(extensions.entries())
.filter(([_, 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: bookData ? {
romajiTitle: bookData.title.romaji,
englishTitle: bookData.title.english || "",
startDate: bookData.startDate || { year: 0, month: 0, day: 0 }
} : { romajiTitle: searchTitle!, englishTitle: searchTitle!, startDate: { year: 0, month: 0, day: 0 } }
});
if (matches?.length) {
const best = matches[0];
const chaps = await ext.findChapters!(best.id);
if (chaps?.length) {
console.log(`[${name}] Found ${chaps.length} chapters.`);
chaps.forEach((ch: { id: any; number: { toString: () => string; }; title: any; releaseDate: any; }) => {
allChapters.push({
id: ch.id,
number: parseFloat(ch.number.toString()),
title: ch.title,
date: ch.releaseDate,
provider: name
});
});
}
} else {
console.log(`[${name}] No matches found for book.`);
}
} catch (e) {
const error = e as Error;
console.error(`Failed to fetch chapters from ${name}:`, error.message);
}
});
await Promise.all(searchPromises);
return { chapters: allChapters.sort((a, b) => Number(a.number) - Number(b.number)) };
}
export async function getChapterContent(bookId: string, chapterIndex: string, providerName: string): Promise<ChapterContent> {
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.findChapterPages) {
throw new Error("Extension doesn't support findChapterPages");
}
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) {
const error = err as Error;
console.error(`[Chapter] Error loading from ${providerName}:`, error.message);
throw err;
}
}