diff --git a/.env.example b/.env.example
index 1e71e2d..6d00e88 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,4 @@
DISCORD_TOKEN=
DISCORD_USER_ID=
DISCORD_GUILD_ID=
+OMDB_API_KEY=
diff --git a/content/about.json b/content/about.json
index 9a4f934..bd7b29e 100644
--- a/content/about.json
+++ b/content/about.json
@@ -1,4 +1,5 @@
{
+ "anilistUsername": "SirBlob",
"hobbies": [
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
@@ -23,6 +24,6 @@
],
"movies": [
{ "name": "Interstellar", "rating": 10, "url": "https://www.imdb.com/title/tt0816692/" },
- { "name": "How to Train Your Dragon", "rating": 10, "url": "https://www.imdb.com/title/tt1621800/" }
+ { "name": "How to Train Your Dragon", "rating": 10, "url": "https://www.imdb.com/title/tt0892769/" }
]
}
diff --git a/src/env.ts b/src/env.ts
index ae3b611..0c532ac 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -14,5 +14,9 @@ export const variables = defineEnvVars({
DISCORD_GUILD_ID: {
schema: optional,
description: 'Guild shared by the bot and the user, needed to read presence'
+ },
+ OMDB_API_KEY: {
+ schema: optional,
+ description: 'OMDb API key used to load posters and details for shows and movies'
}
});
diff --git a/src/lib/anime/types.ts b/src/lib/anime/types.ts
new file mode 100644
index 0000000..75c9482
--- /dev/null
+++ b/src/lib/anime/types.ts
@@ -0,0 +1,31 @@
+export interface AnimeEntry {
+ id: number;
+ title: string;
+ url: string;
+ cover: string;
+ score: number;
+ progress: number;
+ episodes: number | null;
+}
+
+export interface AnimeStats {
+ count: number;
+ episodesWatched: number;
+ daysWatched: number;
+}
+
+export interface AnimeStatusCount {
+ status: string;
+ label: string;
+ count: number;
+}
+
+export interface AnimeProfile {
+ name: string;
+ profileUrl: string;
+ stats: AnimeStats;
+ statusCounts: AnimeStatusCount[];
+ watching: AnimeEntry[];
+ topRated: AnimeEntry[];
+ completedCount: number;
+}
diff --git a/src/lib/components/about/AnimeProfileHeader.svelte b/src/lib/components/about/AnimeProfileHeader.svelte
new file mode 100644
index 0000000..5bd527a
--- /dev/null
+++ b/src/lib/components/about/AnimeProfileHeader.svelte
@@ -0,0 +1,91 @@
+
+
+
+
+ {anime.name.toLowerCase()}@{hostName}
+
+
{'-'.repeat(anime.name.length + hostName.length + 1)}
+
+ {#each rows as [label, value] (label)}
+
{label}: {value}
+ {/each}
+
+ {#each shownStatuses as item (item.status)}
+
+ {item.label}: {item.count}
+
+ {/each}
+
+ {#if listTotal > 0}
+
+ {#each shownStatuses as item (item.status)}
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/src/lib/components/about/AnimeSection.svelte b/src/lib/components/about/AnimeSection.svelte
new file mode 100644
index 0000000..4838a4b
--- /dev/null
+++ b/src/lib/components/about/AnimeSection.svelte
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+
+ {#if anime.watching.length}
+
+
+
+ {/if}
+
+
+ Top rated
+
+
+
+ {#if anime.topRated.length > collapsedCount}
+
+ {/if}
+ Full list on AniList ({anime.completedCount} completed)
+
+
+
diff --git a/src/lib/components/about/MediaCard.svelte b/src/lib/components/about/MediaCard.svelte
new file mode 100644
index 0000000..8fbad02
--- /dev/null
+++ b/src/lib/components/about/MediaCard.svelte
@@ -0,0 +1,140 @@
+
+
+
+
+ {#if details?.poster}
+

+ {:else}
+
+ {/if}
+
+
{item.rating}/10
+
+ {#if details?.plot}
+
{details.plot}
+ {/if}
+
+
+
+ {item.name}
+ {#if metaParts.length}
+ {metaParts.join(' ยท ')}
+ {/if}
+ {#if details?.genre}
+ {details.genre}
+ {/if}
+
+
+
+
diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts
new file mode 100644
index 0000000..4175887
--- /dev/null
+++ b/src/lib/media/types.ts
@@ -0,0 +1,7 @@
+export interface MediaDetails {
+ poster: string | null;
+ year: string | null;
+ genre: string | null;
+ runtime: string | null;
+ plot: string | null;
+}
diff --git a/src/lib/server/anilist.ts b/src/lib/server/anilist.ts
new file mode 100644
index 0000000..6db544e
--- /dev/null
+++ b/src/lib/server/anilist.ts
@@ -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 = {
+ 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();
+
+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 {
+ 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;
+ }
+}
diff --git a/src/lib/server/omdb.ts b/src/lib/server/omdb.ts
new file mode 100644
index 0000000..740870d
--- /dev/null
+++ b/src/lib/server/omdb.ts
@@ -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();
+
+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 {
+ 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> {
+ 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));
+}
diff --git a/src/routes/about/+page.server.ts b/src/routes/about/+page.server.ts
new file mode 100644
index 0000000..fcb8564
--- /dev/null
+++ b/src/routes/about/+page.server.ts
@@ -0,0 +1,15 @@
+import { about } from '#lib/content';
+import { getAnimeProfile } from '#lib/server/anilist';
+import { getMediaDetails } from '#lib/server/omdb';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async () => {
+ const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url);
+
+ const [anime, mediaDetails] = await Promise.all([
+ about.anilistUsername ? getAnimeProfile(about.anilistUsername) : null,
+ getMediaDetails(mediaUrls)
+ ]);
+
+ return { anime, mediaDetails };
+};
diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte
index 23f96ae..59d3c88 100644
--- a/src/routes/about/+page.svelte
+++ b/src/routes/about/+page.svelte
@@ -1,5 +1,4 @@
-
+
@@ -37,7 +46,7 @@
-
+
{#each about.games as game (game.name)}
@@ -47,20 +56,21 @@
- {#each [{ id: 'shows', title: 'TV Shows', items: shows }, { id: 'movies', title: 'Movies', items: movies }] as group (group.id)}
+ {#each mediaGroups as group (group.id)}
-
+
{/each}
+
+ {#if data.anime}
+
+ {/if}