import { getCachedExtension, cacheExtension, getCache, setCache, getExtensionTitle } from '../../shared/queries'; import { queryOne, queryAll, run } from '../../shared/database'; import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions'; import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types'; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const ANILIST_URL = "https://graphql.anilist.co"; async function fetchAniList(query: string, variables: any) { 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 `; export async function getBookById(id: string | number): Promise { const row = await queryOne( "SELECT full_data FROM books WHERE id = ?", [id] ); if (row) { const parsed = JSON.parse(row.full_data); const hasRelationImages = parsed?.relations?.edges?.[0]?.node?.coverImage?.large; const hasCharacterImages = parsed?.characters?.nodes?.[0]?.image?.large; if (hasRelationImages && hasCharacterImages) { return parsed; } console.log(`[Book] Cache outdated for ID ${id}, refetching...`); } 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 } coverImage { large medium } } } } characters(page: 1, perPage: 10) { nodes { id name { full } image { large medium } } } 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 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" }; } export async function refreshTrendingBooks(): Promise { 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 queryOne("DELETE FROM trending_books"); let rank = 1; for (const book of list) { await queryOne( "INSERT INTO trending_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", [rank++, book.id, JSON.stringify(book), now] ); } } export async function refreshPopularBooks(): Promise { 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 queryOne("DELETE FROM popular_books"); let rank = 1; for (const book of list) { await queryOne( "INSERT INTO popular_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", [rank++, book.id, JSON.stringify(book), now] ); } } 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) { 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 cacheExtension(extName, id, normalized.title, normalized); return [normalized]; } } 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) { 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, url: m.url, })); } } 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, lookupId: string, cacheId: string, search: boolean, origin: string, disableCache = false): Promise { const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${cacheId}`; if (!disableCache) { 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: ${lookupId}`); try { return JSON.parse(cached.result) as ChapterWithProvider[]; } catch (e) { console.error(`[${name}] Error parsing cached chapters:`, e); } } } } try { console.log(`[${name}] Searching chapters for: ${lookupId}`); let mediaId: string; if (search) { const matches = await ext.search!({ query: lookupId, media: { romajiTitle: lookupId, englishTitle: lookupId, startDate: { year: 0, month: 0, day: 0 } } }); if (!matches?.length) return []; const nq = normalize(lookupId); const scored = matches.map(m => { const nt = normalize(m.title); let score = similarity(nq, nt); if (nt === nq || nt.includes(nq)) score += 0.5; return { m, score }; }); scored.sort((a, b) => b.score - a.score); if (scored[0].score < 0.4) return []; mediaId = scored[0].m.id; } else { const match = await ext.getMetadata(lookupId); 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, language: ch.language ?? null, })); 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, extensionBookId?: 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 (extensionBookId && name === onlyProvider) { const targetId = extensionBookId ?? id; const chapters = await searchChaptersInExtension( ext, name, targetId, // lookup id, // cache siempre con el id normal false, exts, Boolean(extensionBookId) ); allChapters.push(...chapters); } else { const chapters = await searchChaptersInExtension( ext, name, searchTitle, id, // cache con id normal true, exts ); allChapters.push(...chapters); } } return { chapters: allChapters.sort((a, b) => Number(a.number) - Number(b.number)) }; } export async function getChapterContent(bookId: string, chapterId: string, providerName: string, source: string, lang: string): Promise { const extensions = getAllExtensions(); const ext = extensions.get(providerName); if (!ext) { throw new Error("Provider not found"); } const contentCacheKey = `content:${providerName}:${source}:${lang}:${bookId}:${chapterId}`; 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 ${chapterId}`); 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 ${chapterId}`); } } const selectedChapter: any = { id: chapterId, provider: providerName }; try { if (!ext.findChapterPages) { throw new Error("Extension doesn't support findChapterPages"); } let contentResult: ChapterContent; if (ext.mediaType === "manga") { // Usamos el ID directamente const pages = await ext.findChapterPages(chapterId); contentResult = { type: "manga", chapterId: selectedChapter.id, title: selectedChapter.title, number: selectedChapter.number, provider: providerName, pages }; } else if (ext.mediaType === "ln") { const content = await ext.findChapterPages(chapterId); contentResult = { type: "ln", chapterId: selectedChapter.id, title: selectedChapter.title, number: selectedChapter.number, 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; } } function similarity(s1: string, s2: string): number { const str1 = normalize(s1); const str2 = normalize(s2); const longer = str1.length > str2.length ? str1 : str2; const shorter = str1.length > str2.length ? str2 : str1; if (longer.length === 0) return 1.0; const editDistance = levenshteinDistance(longer, shorter); return (longer.length - editDistance) / longer.length; } function levenshteinDistance(s1: string, s2: string): number { const costs: number[] = []; for (let i = 0; i <= s1.length; i++) { let lastValue = i; for (let j = 0; j <= s2.length; j++) { if (i === 0) { costs[j] = j; } else if (j > 0) { let newValue = costs[j - 1]; if (s1.charAt(i - 1) !== s2.charAt(j - 1)) { newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1; } costs[j - 1] = lastValue; lastValue = newValue; } } if (i > 0) costs[s2.length] = lastValue; } return costs[s2.length]; } function normalize(str: string): string { return str .toLowerCase() .replace(/'/g, "'") // decodificar entidades HTML .replace(/[^\w\s]/g, ' ') // convertir puntuación a espacios .replace(/\s+/g, ' ') // normalizar espacios .trim(); }