120 lines
3.4 KiB
TypeScript
120 lines
3.4 KiB
TypeScript
import type { AnimeEntry, AnimeProfile } from '#lib/anime/types';
|
|
|
|
const cacheDuration = 60 * 60 * 1000;
|
|
const minutesPerDay = 60 * 24;
|
|
const topRatedMinimumScore = 9;
|
|
const statusLabels: Record<string, string> = {
|
|
COMPLETED: 'Completed',
|
|
CURRENT: 'Watching',
|
|
PLANNING: 'Planning',
|
|
PAUSED: 'Paused',
|
|
DROPPED: 'Dropped'
|
|
};
|
|
|
|
const animeQuery = `
|
|
query ($userName: String) {
|
|
User(name: $userName) {
|
|
name
|
|
siteUrl
|
|
statistics { anime { count episodesWatched minutesWatched } }
|
|
}
|
|
MediaListCollection(userName: $userName, type: ANIME) {
|
|
lists {
|
|
status
|
|
entries {
|
|
score(format: POINT_10)
|
|
progress
|
|
media { id siteUrl episodes title { english romaji } coverImage { large } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
interface AniListEntry {
|
|
score: number;
|
|
progress: number;
|
|
media: {
|
|
id: number;
|
|
siteUrl: string;
|
|
episodes: number | null;
|
|
title: { english: string | null; romaji: string };
|
|
coverImage: { large: string };
|
|
};
|
|
}
|
|
|
|
interface AniListResponse {
|
|
data: {
|
|
User: {
|
|
name: string;
|
|
siteUrl: string;
|
|
statistics: { anime: { count: number; episodesWatched: number; minutesWatched: number } };
|
|
};
|
|
MediaListCollection: { lists: { status: string; entries: AniListEntry[] }[] };
|
|
};
|
|
}
|
|
|
|
const profileCache = new Map<string, { profile: AnimeProfile; fetchedAt: number }>();
|
|
|
|
function toAnimeEntry({ score, progress, media }: AniListEntry): AnimeEntry {
|
|
return {
|
|
id: media.id,
|
|
title: media.title.english ?? media.title.romaji,
|
|
url: media.siteUrl,
|
|
cover: media.coverImage.large,
|
|
score,
|
|
progress,
|
|
episodes: media.episodes
|
|
};
|
|
}
|
|
|
|
function entriesWithStatus(response: AniListResponse, status: string): AnimeEntry[] {
|
|
const list = response.data.MediaListCollection.lists.find((item) => item.status === status);
|
|
return (list?.entries ?? []).map(toAnimeEntry);
|
|
}
|
|
|
|
function toAnimeProfile(response: AniListResponse): AnimeProfile {
|
|
const { name, siteUrl, statistics } = response.data.User;
|
|
const completed = entriesWithStatus(response, 'COMPLETED');
|
|
const statusCounts = Object.entries(statusLabels).map(([status, label]) => ({
|
|
status,
|
|
label,
|
|
count: entriesWithStatus(response, status).length
|
|
}));
|
|
|
|
return {
|
|
name,
|
|
profileUrl: siteUrl,
|
|
statusCounts,
|
|
stats: {
|
|
count: statistics.anime.count,
|
|
episodesWatched: statistics.anime.episodesWatched,
|
|
daysWatched: Math.round((statistics.anime.minutesWatched / minutesPerDay) * 10) / 10
|
|
},
|
|
watching: entriesWithStatus(response, 'CURRENT'),
|
|
topRated: completed.filter((entry) => entry.score >= topRatedMinimumScore).sort((first, second) => second.score - first.score),
|
|
completedCount: completed.length
|
|
};
|
|
}
|
|
|
|
export async function getAnimeProfile(userName: string): Promise<AnimeProfile | null> {
|
|
const cached = profileCache.get(userName);
|
|
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: animeQuery, variables: { userName } })
|
|
});
|
|
if (!response.ok) throw new Error(`AniList returned ${response.status}`);
|
|
|
|
const profile = toAnimeProfile(await response.json());
|
|
profileCache.set(userName, { profile, fetchedAt: Date.now() });
|
|
return profile;
|
|
} catch (error) {
|
|
console.error(`AniList lookup failed for ${userName}:`, error);
|
|
return cached?.profile ?? null;
|
|
}
|
|
}
|