172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
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<string, string>;
|
|
stats: (statistics: MediaStatistics) => AniListStat[];
|
|
}
|
|
|
|
const mediaConfigs: Record<AniListMediaType, MediaConfig> = {
|
|
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<string, MediaStatistics>;
|
|
};
|
|
MediaListCollection: { lists: { status: string | null; entries: ResponseEntry[] }[] };
|
|
};
|
|
}
|
|
|
|
const profileCache = new Map<string, { profile: AniListProfile; fetchedAt: number }>();
|
|
|
|
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<AniListProfile | null> {
|
|
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;
|
|
}
|
|
}
|