Add anime, shows and movies to about page
This commit is contained in:
1 parent
d7476cff98
commit
7845cb5e6f
12 files changed
+754
-22
No files matched your search
@@ -0,0 +1,119 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { OMDB_API_KEY } from '$app/env/private';
|
||||
import type { MediaDetails } from '#lib/media/types';
|
||||
|
||||
const cacheDuration = 24 * 60 * 60 * 1000;
|
||||
const imdbIdPattern = /tt\d+/;
|
||||
const missingValue = 'N/A';
|
||||
|
||||
interface OmdbResponse {
|
||||
Response: 'True' | 'False';
|
||||
Error?: string;
|
||||
Year?: string;
|
||||
Genre?: string;
|
||||
Runtime?: string;
|
||||
Plot?: string;
|
||||
Poster?: string;
|
||||
}
|
||||
|
||||
const detailsCache = new Map<string, { details: MediaDetails | null; fetchedAt: number }>();
|
||||
|
||||
function imdbIdFromUrl(url: string): string | null {
|
||||
return url.match(imdbIdPattern)?.[0] ?? null;
|
||||
}
|
||||
|
||||
function presentValue(value: string | undefined): string | null {
|
||||
return value && value !== missingValue ? value : null;
|
||||
}
|
||||
|
||||
function toMediaDetails(result: OmdbResponse): MediaDetails {
|
||||
return {
|
||||
poster: presentValue(result.Poster),
|
||||
year: presentValue(result.Year),
|
||||
genre: presentValue(result.Genre),
|
||||
runtime: presentValue(result.Runtime),
|
||||
plot: presentValue(result.Plot)
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDetails(imdbId: string): Promise<MediaDetails | null> {
|
||||
const cached = detailsCache.get(imdbId);
|
||||
if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.details;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ apikey: OMDB_API_KEY ?? '', i: imdbId, plot: 'short' });
|
||||
const response = await fetch(`https://www.omdbapi.com/?${params}`);
|
||||
if (!response.ok) throw new Error(`OMDb returned ${response.status}`);
|
||||
|
||||
const result: OmdbResponse = await response.json();
|
||||
if (result.Response === 'False') throw new Error(result.Error ?? 'OMDb found no result');
|
||||
|
||||
const details = toMediaDetails(result);
|
||||
detailsCache.set(imdbId, { details, fetchedAt: Date.now() });
|
||||
return details;
|
||||
} catch (error) {
|
||||
console.error(`OMDb lookup failed for ${imdbId}:`, error);
|
||||
return cached?.details ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMediaDetails(urls: string[]): Promise<Record<string, MediaDetails>> {
|
||||
if (!OMDB_API_KEY) return {};
|
||||
|
||||
const results = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
const imdbId = imdbIdFromUrl(url);
|
||||
const details = imdbId ? await fetchDetails(imdbId) : null;
|
||||
return [url, details] as const;
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(results.filter((result): result is readonly [string, MediaDetails] => result[1] !== null));
|
||||
}
|
||||
Reference in new issue
Block a user