import { getCachedExtension, cacheExtension, getCache, setCache, getExtensionTitle } from '../../shared/queries'; import { queryOne, queryAll } from '../../shared/database'; import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions'; import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types'; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; export async function getBookById(id: string | number): Promise { 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 { 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 { 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 { 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 { 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 getBookInfoExtension(ext: Extension | null, id: string): Promise { if (!ext) return []; const extName = ext.constructor.name; const cached = await 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) { await cacheExtension(extName, id, info.title, info); return info; } } catch (e) { console.error(`Extension getInfo failed:`, e); } } return []; } export async function searchBooksInExtension(ext: Extension | null, name: string, query: string): Promise { if (!ext) return []; if ((ext.type === 'book-board') && ext.search) { const start = performance.now(); 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 } } }); const end = performance.now(); console.log(`[${name}] Search time: ${(end - start).toFixed(2)} ms`); console.log(`[${name}] Search time: ${(end - start).toFixed(2)} ms`); console.log(`[${name}] Search time: ${(end - start).toFixed(2)} ms`); console.log(`[${name}] Search time: ${(end - start).toFixed(2)} ms`); console.log(`[${name}] Search time: ${(end - start).toFixed(2)} ms`); console.log(`[${name}] Search time: ${(end - start).toFixed(2)} ms`); console.log(`[${name}] Search time: ${(end - start).toFixed(2)} ms`); 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: string): Promise { 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: Extension, name: string, searchTitle: string, search: boolean, origin: string): Promise { const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${searchTitle}`; const cached = await 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) as ChapterWithProvider[]; } 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: string; 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: ChapterWithProvider[] = chaps.map((ch) => ({ id: ch.id, number: parseFloat(ch.number.toString()), title: ch.title, date: ch.releaseDate, provider: name, index: ch.index })); await setCache(cacheKey, result, CACHE_TTL_MS); return result; } catch (e) { const error = e as Error; console.error(`Failed to fetch chapters from ${name}:`, error.message); return []; } } export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string): Promise<{ chapters: ChapterWithProvider[] }> { let bookData: Book | null = null; let searchTitle: string = ""; 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) as string[]; searchTitle = titles[0]; } const bookExtensions = getBookExtensionsMap(); let extension; if (!searchTitle) { for (const [name, ext] of bookExtensions) { const title = await getExtensionTitle(name, id) if (title){ searchTitle = title; extension = name; } } } const allChapters: any[] = []; 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)) }; } export async function getChapterContent(bookId: string, chapterIndex: string, providerName: string, source: string): Promise { const extensions = getAllExtensions(); const ext = extensions.get(providerName); if (!ext) { throw new Error("Provider not found"); } const contentCacheKey = `content:${providerName}:${source}:${bookId}:${chapterIndex}`; const cachedContent = await 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) as ChapterContent; } 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: ChapterContent; 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 setCache(contentCacheKey, contentResult, CACHE_TTL_MS); return contentResult; } catch (err) { const error = err as Error; console.error(`[Chapter] Error loading from ${providerName}:`, error.message); throw err; } }