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} + + {/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} + +
+ {#each anime.watching as entry (entry.id)} + + {entry.title} +
+ {entry.title} +
+
+
+ Episode {entry.progress} / {entry.episodes ?? '?'} +
+
+ {/each} +
+
+ {/if} +
+ +

Top rated

+
+ {#each shownTopRated as entry (entry.id)} + +
+ {entry.title} + {entry.score} +
+ {entry.title} +
+ {/each} +
+ +
+ {#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)} {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 group.items as item (item.name)} - -
- - {item.rating}/10 -
-
+ {/each}
{/each} + + {#if data.anime} +
+ +
+ {/if}