import { getCache, setCache, getCachedExtension, cacheExtension, getExtensionTitle } from '../../shared/queries'; import { queryAll, queryOne } from '../../shared/database'; import {Anime, Episode, Extension, StreamData} from '../types'; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const ANILIST_URL = "https://graphql.anilist.co"; const MEDIA_FIELDS = ` id idMal title { romaji english native userPreferred } type format status description startDate { year month day } endDate { year month day } season seasonYear episodes duration chapters volumes countryOfOrigin isLicensed source hashtag trailer { id site thumbnail } updatedAt coverImage { extraLarge large medium color } bannerImage genres synonyms averageScore popularity isLocked trending favourites isAdult siteUrl tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult } relations { edges { relationType node { id title { romaji } type format status } } } studios { edges { isMain node { id name isAnimationStudio } } } nextAiringEpisode { airingAt timeUntilAiring episode } externalLinks { id url site type language color icon notes } rankings { id rank type format year season allTime context } stats { scoreDistribution { score amount } statusDistribution { status amount } } recommendations(perPage: 7, sort: RATING_DESC) { nodes { mediaRecommendation { id title { romaji } coverImage { medium } format type } } } `; export async function refreshTrendingAnime(): Promise { const query = ` query { Page(page: 1, perPage: 10) { media(type: ANIME, 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"); let rank = 1; for (const anime of list) { await queryOne( "INSERT INTO trending (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", [rank++, anime.id, JSON.stringify(anime), now] ); } } export async function refreshTopAiringAnime(): Promise { const query = ` query { Page(page: 1, perPage: 10) { media(type: ANIME, status: RELEASING, sort: SCORE_DESC) { ${MEDIA_FIELDS} } } } `; const data = await fetchAniList(query, {}); const list = data?.Page?.media || []; const now = Math.floor(Date.now() / 1000); await queryOne("DELETE FROM top_airing"); let rank = 1; for (const anime of list) { await queryOne( "INSERT INTO top_airing (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", [rank++, anime.id, JSON.stringify(anime), now] ); } } async function fetchAniList(query: string, variables: any) { const res = await fetch(ANILIST_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) }); const json = await res.json(); return json?.data; } export async function getAnimeById(id: string | number): Promise { const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]); if (row) return JSON.parse(row.full_data); const query = ` query ($id: Int) { Media(id: $id, type: ANIME) { ${MEDIA_FIELDS} } } `; const data = await fetchAniList(query, { id: Number(id) }); if (!data?.Media) return { error: "Anime not found" }; await queryOne( "INSERT INTO anime (id, title, updatedAt, full_data) VALUES (?, ?, ?, ?)", [ data.Media.id, data.Media.title?.english || data.Media.title?.romaji, data.Media.updatedAt || 0, JSON.stringify(data.Media) ] ); return data.Media; } export async function getTrendingAnime(): Promise { const rows = await queryAll( "SELECT full_data FROM trending ORDER BY rank ASC LIMIT 10" ); return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data)); } export async function getTopAiringAnime(): Promise { const rows = await queryAll( "SELECT full_data FROM top_airing ORDER BY rank ASC LIMIT 10" ); return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data)); } export async function searchAnimeLocal(query: string): Promise { if (!query || query.length < 2) return []; const sql = `SELECT full_data FROM anime WHERE full_data LIKE ? LIMIT 50`; const rows = await queryAll(sql, [`%${query}%`]); const localResults: Anime[] = rows .map((r: { full_data: string }) => JSON.parse(r.full_data)) .filter((anime: { title: { english: any; romaji: any; native: any; }; synonyms: any; }) => { const q = query.toLowerCase(); const titles = [ anime.title?.english, anime.title?.romaji, anime.title?.native, ...(anime.synonyms || []) ] .filter(Boolean) .map(t => t!.toLowerCase()); return titles.some(t => t.includes(q)); }) .slice(0, 10); if (localResults.length >= 5) { return localResults; } const gql = ` query ($search: String) { Page(page: 1, perPage: 10) { media(type: ANIME, search: $search) { ${MEDIA_FIELDS} } } } `; const data = await fetchAniList(gql, { search: query }); const remoteResults: Anime[] = data?.Page?.media || []; for (const anime of remoteResults) { await queryOne( "INSERT OR IGNORE INTO anime (id, title, updatedAt, full_data) VALUES (?, ?, ?, ?)", [ anime.id, anime.title?.english || anime.title?.romaji, anime.updatedAt || 0, JSON.stringify(anime) ] ); } const merged = [...localResults]; for (const anime of remoteResults) { if (!merged.find(a => a.id === anime.id)) { merged.push(anime); } if (merged.length >= 10) break; } return merged; } export async function getAnimeInfoExtension(ext: Extension | null, id: string): Promise { if (!ext) return { error: "not found" }; const extName = ext.constructor.name; const cached = await getCachedExtension(extName, id); if (cached) { try { console.log(`[${extName}] Metadata cache hit for ID: ${id}`); return JSON.parse(cached.metadata) as Anime; } catch { } } if ((ext.type === 'anime-board') && ext.getMetadata) { try { const match = await ext.getMetadata(id); if (match) { const normalized: any = { title: match.title ?? "Unknown", summary: match.summary ?? "No summary available", episodes: Number(match.episodes) || 0, characters: Array.isArray(match.characters) ? match.characters : [], season: match.season ?? null, status: match.status ?? "Unknown", studio: match.studio ?? "Unknown", score: Number(match.score) || 0, year: match.year ?? null, genres: Array.isArray(match.genres) ? match.genres : [], image: match.image ?? "" }; await cacheExtension(extName, id, normalized.title, normalized); return normalized; } } catch (e) { console.error(`Extension getMetadata failed:`, e); } } return { error: "not found" }; } export async function searchAnimeInExtension(ext: Extension | null, name: string, query: string) { if (!ext) return []; if (ext.type === 'anime-board' && ext.search) { try { console.log(`[${name}] Searching for anime: ${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: 'ANIME', seasonYear: null, isExtensionResult: true, })); } } catch (e) { console.error(`Extension search failed for ${name}:`, e); } } return []; } export async function searchEpisodesInExtension(ext: Extension | null, name: string, query: string): Promise { if (!ext) return []; const cacheKey = `anime:episodes:${name}:${query}`; const cached = await getCache(cacheKey); if (cached) { const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS; if (!isExpired) { console.log(`[${name}] Episodes cache hit for: ${query}`); try { return JSON.parse(cached.result) as Episode[]; } catch (e) { console.error(`[${name}] Error parsing cached episodes:`, e); } } else { console.log(`[${name}] Episodes cache expired for: ${query}`); } } if (ext.type === "anime-board" && ext.search && typeof ext.findEpisodes === "function") { try { const title = await getExtensionTitle(name, query); let mediaId: string; if (!title) { const matches = await ext.search({ query, media: { romajiTitle: query, englishTitle: query, startDate: { year: 0, month: 0, day: 0 } } }); if (!matches || matches.length === 0) return []; const res = matches[0]; if (!res?.id) return []; mediaId = res.id; } else { mediaId = query; } const chapterList = await ext.findEpisodes(mediaId); if (!Array.isArray(chapterList)) return []; const result: Episode[] = chapterList.map(ep => ({ id: ep.id, number: ep.number, url: ep.url, title: ep.title })); await setCache(cacheKey, result, CACHE_TTL_MS); return result; } catch (e) { console.error(`Extension search failed for ${name}:`, e); } } return []; } export async function getStreamData(extension: Extension, episode: string, id: string, source: string, server?: string, category?: string): Promise { const providerName = extension.constructor.name; const cacheKey = `anime:stream:${providerName}:${id}:${episode}:${server || 'default'}:${category || 'sub'}`; const cached = await getCache(cacheKey); if (cached) { const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS; if (!isExpired) { console.log(`[${providerName}] Stream data cache hit for episode ${episode}`); try { return JSON.parse(cached.result) as StreamData; } catch (e) { console.error(`[${providerName}] Error parsing cached stream data:`, e); } } else { console.log(`[${providerName}] Stream data cache expired for episode ${episode}`); } } if (!extension.findEpisodes || !extension.findEpisodeServer) { throw new Error("Extension doesn't support required methods"); } let episodes; if (source === "anilist"){ const anime: any = await getAnimeById(id) episodes = await searchEpisodesInExtension(extension, extension.constructor.name, anime.title.romaji); } else{ episodes = await extension.findEpisodes(id); } const targetEp = episodes.find(e => e.number === parseInt(episode)); if (!targetEp) { throw new Error("Episode not found"); } const serverName = server || "default"; const streamData = await extension.findEpisodeServer(targetEp, serverName); await setCache(cacheKey, streamData, CACHE_TTL_MS); return streamData; }