Files
WaifuBoard/electron/api/books/books.service.js
2025-12-15 16:21:06 +01:00

494 lines
17 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getBookById = getBookById;
exports.getTrendingBooks = getTrendingBooks;
exports.getPopularBooks = getPopularBooks;
exports.searchBooksLocal = searchBooksLocal;
exports.searchBooksAniList = searchBooksAniList;
exports.getBookInfoExtension = getBookInfoExtension;
exports.searchBooksInExtension = searchBooksInExtension;
exports.getChaptersForBook = getChaptersForBook;
exports.getChapterContent = getChapterContent;
const queries_1 = require("../../shared/queries");
const database_1 = require("../../shared/database");
const extensions_1 = require("../../shared/extensions");
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const TTL = 60 * 60 * 6;
const ANILIST_URL = "https://graphql.anilist.co";
async function fetchAniList(query, variables) {
const res = await fetch(ANILIST_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({ query, variables })
});
if (!res.ok) {
throw new Error(`AniList error ${res.status}`);
}
const json = await res.json();
return json?.data;
}
const MEDIA_FIELDS = `
id
title {
romaji
english
native
userPreferred
}
type
format
status
description
startDate { year month day }
endDate { year month day }
season
seasonYear
episodes
chapters
volumes
duration
genres
synonyms
averageScore
popularity
favourites
isAdult
siteUrl
coverImage {
extraLarge
large
medium
color
}
bannerImage
updatedAt
`;
async function getBookById(id) {
const row = await (0, database_1.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?.Media) {
const media = data.data.Media;
const insertSql = `
INSERT INTO books (id, title, updatedAt, full_data)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = EXCLUDED.title,
updatedAt = EXCLUDED.updatedAt,
full_data = EXCLUDED.full_data;
`;
await (0, database_1.run)(insertSql, [
media.id,
media.title?.userPreferred || media.title?.romaji || media.title?.english || null,
media.updatedAt || Math.floor(Date.now() / 1000),
JSON.stringify(media)
]);
return media;
}
}
catch (e) {
console.error("Fetch error:", e);
}
return { error: "Book not found" };
}
async function getTrendingBooks() {
const rows = await (0, database_1.queryAll)("SELECT full_data, updated_at FROM trending_books ORDER BY rank ASC LIMIT 10");
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r) => JSON.parse(r.full_data));
}
}
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: MANGA, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await (0, database_1.queryOne)("DELETE FROM trending_books");
let rank = 1;
for (const book of list) {
await (0, database_1.queryOne)("INSERT INTO trending_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", [rank++, book.id, JSON.stringify(book), now]);
}
return list;
}
async function getPopularBooks() {
const rows = await (0, database_1.queryAll)("SELECT full_data, updated_at FROM popular_books ORDER BY rank ASC LIMIT 10");
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r) => JSON.parse(r.full_data));
}
}
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: MANGA, sort: POPULARITY_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await (0, database_1.queryOne)("DELETE FROM popular_books");
let rank = 1;
for (const book of list) {
await (0, database_1.queryOne)("INSERT INTO popular_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", [rank++, book.id, JSON.stringify(book), now]);
}
return list;
}
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 (0, database_1.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 getBookInfoExtension(ext, id) {
if (!ext)
return [];
const extName = ext.constructor.name;
const cached = await (0, queries_1.getCachedExtension)(extName, id);
if (cached) {
try {
return JSON.parse(cached.metadata);
}
catch { }
}
if (ext.type === 'book-board' && ext.getMetadata) {
try {
const info = await ext.getMetadata(id);
if (info) {
const normalized = {
id: info.id ?? id,
title: info.title ?? "",
format: info.format ?? "",
score: typeof info.score === "number" ? info.score : null,
genres: Array.isArray(info.genres) ? info.genres : [],
status: info.status ?? "",
published: info.published ?? "",
summary: info.summary ?? "",
chapters: Number.isFinite(info.chapters) ? info.chapters : 1,
image: typeof info.image === "string" ? info.image : ""
};
await (0, queries_1.cacheExtension)(extName, id, normalized.title, normalized);
return [normalized];
}
}
catch (e) {
console.error(`Extension getInfo failed:`, e);
}
}
return [];
}
async function searchBooksInExtension(ext, name, query) {
if (!ext)
return [];
if ((ext.type === 'book-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?.length) {
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: m.format,
seasonYear: null,
isExtensionResult: true
}));
}
}
catch (e) {
console.error(`Extension search failed for ${name}:`, e);
}
}
return [];
}
async function fetchBookMetadata(id) {
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();
return d.data?.Media || null;
}
catch (e) {
console.error("Failed to fetch book metadata:", e);
return null;
}
}
async function searchChaptersInExtension(ext, name, searchTitle, search, origin) {
const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${searchTitle}`;
const cached = await (0, queries_1.getCache)(cacheKey);
if (cached) {
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
if (!isExpired) {
console.log(`[${name}] Chapters cache hit for: ${searchTitle}`);
try {
return JSON.parse(cached.result);
}
catch (e) {
console.error(`[${name}] Error parsing cached chapters:`, e);
}
}
else {
console.log(`[${name}] Chapters cache expired for: ${searchTitle}`);
}
}
try {
console.log(`[${name}] Searching chapters for: ${searchTitle}`);
let mediaId;
if (search) {
const matches = await ext.search({
query: searchTitle,
media: {
romajiTitle: searchTitle,
englishTitle: searchTitle,
startDate: { year: 0, month: 0, day: 0 }
}
});
const best = matches?.[0];
if (!best) {
return [];
}
mediaId = best.id;
}
else {
const match = await ext.getMetadata(searchTitle);
mediaId = match.id;
}
const chaps = await ext.findChapters(mediaId);
if (!chaps?.length) {
return [];
}
console.log(`[${name}] Found ${chaps.length} chapters.`);
const result = chaps.map((ch) => ({
id: ch.id,
number: parseFloat(ch.number.toString()),
title: ch.title,
date: ch.releaseDate,
provider: name,
index: ch.index
}));
await (0, queries_1.setCache)(cacheKey, result, CACHE_TTL_MS);
return result;
}
catch (e) {
const error = e;
console.error(`Failed to fetch chapters from ${name}:`, error.message);
return [];
}
}
async function getChaptersForBook(id, ext, onlyProvider) {
let bookData = null;
let searchTitle = "";
if (!ext) {
const result = await getBookById(id);
if (!result || "error" in result)
return { chapters: [] };
bookData = result;
const titles = [bookData.title.english, bookData.title.romaji].filter(Boolean);
searchTitle = titles[0];
}
const bookExtensions = (0, extensions_1.getBookExtensionsMap)();
let extension;
if (!searchTitle) {
for (const [name, ext] of bookExtensions) {
const title = await (0, queries_1.getExtensionTitle)(name, id);
if (title) {
searchTitle = title;
extension = name;
}
}
}
const allChapters = [];
let exts = "anilist";
if (ext)
exts = "ext";
for (const [name, ext] of bookExtensions) {
if (onlyProvider && name !== onlyProvider)
continue;
if (name == extension) {
const chapters = await searchChaptersInExtension(ext, name, id, false, exts);
allChapters.push(...chapters);
}
else {
const chapters = await searchChaptersInExtension(ext, name, searchTitle, true, exts);
allChapters.push(...chapters);
}
}
return {
chapters: allChapters.sort((a, b) => Number(a.number) - Number(b.number))
};
}
async function getChapterContent(bookId, chapterIndex, providerName, source) {
const extensions = (0, extensions_1.getAllExtensions)();
const ext = extensions.get(providerName);
if (!ext) {
throw new Error("Provider not found");
}
const contentCacheKey = `content:${providerName}:${source}:${bookId}:${chapterIndex}`;
const cachedContent = await (0, queries_1.getCache)(contentCacheKey);
if (cachedContent) {
const isExpired = Date.now() - cachedContent.created_at > CACHE_TTL_MS;
if (!isExpired) {
console.log(`[${providerName}] Content cache hit for Book ID ${bookId}, Index ${chapterIndex}`);
try {
return JSON.parse(cachedContent.result);
}
catch (e) {
console.error(`[${providerName}] Error parsing cached content:`, e);
}
}
else {
console.log(`[${providerName}] Content cache expired for Book ID ${bookId}, Index ${chapterIndex}`);
}
}
const isExternal = source !== 'anilist';
const chapterList = await getChaptersForBook(bookId, isExternal, providerName);
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");
}
let contentResult;
if (ext.mediaType === "manga") {
const pages = await ext.findChapterPages(chapterId);
contentResult = {
type: "manga",
chapterId,
title: chapterTitle,
number: chapterNumber,
provider: providerName,
pages
};
}
else if (ext.mediaType === "ln") {
const content = await ext.findChapterPages(chapterId);
contentResult = {
type: "ln",
chapterId,
title: chapterTitle,
number: chapterNumber,
provider: providerName,
content
};
}
else {
throw new Error("Unknown mediaType");
}
await (0, queries_1.setCache)(contentCacheKey, contentResult, CACHE_TTL_MS);
return contentResult;
}
catch (err) {
const error = err;
console.error(`[Chapter] Error loading from ${providerName}:`, error.message);
throw err;
}
}