import type { AniListEntry, AniListMediaType, AniListProfile, AniListStat } from '#lib/anilist/types'; const cacheDuration = 60 * 60 * 1000; const minutesPerDay = 60 * 24; const topRatedMinimumScore = 9; interface MediaStatistics { count: number; episodesWatched?: number; minutesWatched?: number; chaptersRead?: number; volumesRead?: number; } interface MediaConfig { statisticsKey: 'anime' | 'manga'; statisticsFields: string; totalField: 'episodes' | 'chapters'; listPath: string; currentLabel: string; progressUnit: string; totalUnit: string; statusLabels: Record; stats: (statistics: MediaStatistics) => AniListStat[]; } const mediaConfigs: Record = { ANIME: { statisticsKey: 'anime', statisticsFields: 'count episodesWatched minutesWatched', totalField: 'episodes', listPath: 'animelist', currentLabel: 'Currently watching', progressUnit: 'Episode', totalUnit: 'eps', statusLabels: { COMPLETED: 'Completed', CURRENT: 'Watching', PLANNING: 'Planning', PAUSED: 'Paused', DROPPED: 'Dropped' }, stats: (statistics) => [ { label: 'Anime', value: `${statistics.count}` }, { label: 'Episodes', value: `${statistics.episodesWatched ?? 0}` }, { label: 'Watch time', value: `${Math.round(((statistics.minutesWatched ?? 0) / minutesPerDay) * 10) / 10} days` } ] }, MANGA: { statisticsKey: 'manga', statisticsFields: 'count chaptersRead volumesRead', totalField: 'chapters', listPath: 'mangalist', currentLabel: 'Currently reading', progressUnit: 'Chapter', totalUnit: 'ch', statusLabels: { COMPLETED: 'Completed', CURRENT: 'Reading', PLANNING: 'Planning', PAUSED: 'Paused', DROPPED: 'Dropped' }, stats: (statistics) => [ { label: 'Manga', value: `${statistics.count}` }, { label: 'Chapters', value: `${statistics.chaptersRead ?? 0}` }, { label: 'Volumes', value: `${statistics.volumesRead ?? 0}` } ] } }; interface ResponseEntry { score: number; progress: number; media: { id: number; siteUrl: string; episodes?: number | null; chapters?: number | null; title: { english: string | null; romaji: string }; coverImage: { large: string }; }; } interface AniListResponse { data: { User: { name: string; siteUrl: string; statistics: Record; }; MediaListCollection: { lists: { status: string | null; entries: ResponseEntry[] }[] }; }; } const profileCache = new Map(); function buildQuery(config: MediaConfig): string { return ` query ($userName: String, $type: MediaType) { User(name: $userName) { name siteUrl statistics { ${config.statisticsKey} { ${config.statisticsFields} } } } MediaListCollection(userName: $userName, type: $type) { lists { status entries { score(format: POINT_10) progress media { id siteUrl ${config.totalField} title { english romaji } coverImage { large } } } } } } `; } function toEntry({ score, progress, media }: ResponseEntry, config: MediaConfig): AniListEntry { return { id: media.id, title: media.title.english ?? media.title.romaji, url: media.siteUrl, cover: media.coverImage.large, score, progress, total: media[config.totalField] ?? null }; } function entriesWithStatus(response: AniListResponse, status: string, config: MediaConfig): AniListEntry[] { const list = response.data.MediaListCollection.lists.find((item) => item.status === status); return (list?.entries ?? []).map((entry) => toEntry(entry, config)); } function toProfile(response: AniListResponse, mediaType: AniListMediaType): AniListProfile { const config = mediaConfigs[mediaType]; const { name, siteUrl, statistics } = response.data.User; const completed = entriesWithStatus(response, 'COMPLETED', config); return { mediaType, name, profileUrl: siteUrl, listUrl: `${siteUrl}/${config.listPath}`, stats: config.stats(statistics[config.statisticsKey]), statusCounts: Object.entries(config.statusLabels).map(([status, label]) => ({ status, label, count: entriesWithStatus(response, status, config).length })), current: entriesWithStatus(response, 'CURRENT', config), currentLabel: config.currentLabel, progressUnit: config.progressUnit, totalUnit: config.totalUnit, topRated: completed.filter((entry) => entry.score >= topRatedMinimumScore).sort((first, second) => second.score - first.score), planning: entriesWithStatus(response, 'PLANNING', config), completedCount: completed.length }; } export async function getAniListProfile(userName: string, mediaType: AniListMediaType): Promise { const cacheKey = `${userName}:${mediaType}`; const cached = profileCache.get(cacheKey); if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.profile; try { const response = await fetch('https://graphql.anilist.co', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ query: buildQuery(mediaConfigs[mediaType]), variables: { userName, type: mediaType } }) }); if (!response.ok) throw new Error(`AniList returned ${response.status}`); const profile = toProfile(await response.json(), mediaType); profileCache.set(cacheKey, { profile, fetchedAt: Date.now() }); return profile; } catch (error) { console.error(`AniList ${mediaType.toLowerCase()} lookup failed for ${userName}:`, error); return cached?.profile ?? null; } }