code organisation & refactor
This commit is contained in:
289
src/books/books.service.js
Normal file
289
src/books/books.service.js
Normal file
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user