From a87fee111ccb501121b5aa3c1e48601f4869198a Mon Sep 17 00:00:00 2001 From: Fuwn Date: Wed, 20 Dec 2023 02:20:37 -0800 Subject: feat(schedule): match media to anilist --- src/lib/AniList/schedule.ts | 75 +++++++++++++++++++ src/lib/Media/Anime/airing.ts | 24 +++++- src/lib/Media/Anime/season.ts | 11 +++ src/routes/schedule/+page.svelte | 153 ++++++++++++++++++++++++--------------- 4 files changed, 204 insertions(+), 59 deletions(-) create mode 100644 src/lib/AniList/schedule.ts create mode 100644 src/lib/Media/Anime/season.ts diff --git a/src/lib/AniList/schedule.ts b/src/lib/AniList/schedule.ts new file mode 100644 index 00000000..33351a09 --- /dev/null +++ b/src/lib/AniList/schedule.ts @@ -0,0 +1,75 @@ +import type { Media, MediaTitle } from './media'; + +interface SchedulePage { + data: { + Page: { + media: { + title: MediaTitle; + synonyms: string[]; + id: number; + idMal: number; + episodes: number; + nextAiringEpisode?: { + episode: number; + airingAt?: number; + }; + coverImage: { + extraLarge: string; + }; + }[]; + pageInfo: { + hasNextPage: boolean; + }; + }; + }; +} + +const schedulePage = async ( + page: number, + year: number, + season: 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL' +): Promise => + await ( + await fetch('https://graphql.anilist.co', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ + query: `{ + Page(page: ${page}) { + pageInfo { + hasNextPage + } + media(season: ${season}, seasonYear: ${year}) { + id idMal episodes synonyms + title { english romaji native } + nextAiringEpisode { episode airingAt } + coverImage { extraLarge } + } + } +}` + }) + }) + ).json(); + +export const scheduleMediaListCollection = async ( + year: number, + season: 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL' +) => { + const scheduledMedia = []; + let page = 1; + let currentPage = await schedulePage(page, year, season); + + for (const candidate of currentPage.data.Page.media) scheduledMedia.push(candidate); + + while (currentPage['data']['Page']['pageInfo']['hasNextPage']) { + for (const candidate of currentPage.data.Page.media) scheduledMedia.push(candidate); + + page += 1; + currentPage = await schedulePage(page, year, season); + } + + return scheduledMedia as Partial; +}; diff --git a/src/lib/Media/Anime/airing.ts b/src/lib/Media/Anime/airing.ts index 15abbf2a..9c13fb5d 100644 --- a/src/lib/Media/Anime/airing.ts +++ b/src/lib/Media/Anime/airing.ts @@ -6,7 +6,7 @@ import type { SubsPlease } from '$lib/subsPlease'; import levenshtein from 'fast-levenshtein'; import { totalEpisodes } from './episodes'; -interface Time { +export interface Time { title: string; time: string; day: string; @@ -66,6 +66,28 @@ const findClosestMatch = (times: Time[], titles: string[]) => { return closestMatch as Time | null; }; +export const findClosestMedia = (media: Media[], matchFor: string) => { + if (!matchFor) return null; + + let bestFitMedia: Media | null = null; + let smallestDistance = Infinity; + + const normalizedSingleTitle = normalizeTitle(matchFor); + + media.forEach((m) => { + [m.title.romaji, m.title.english, ...m.synonyms].filter(Boolean).forEach((title) => { + const distance = levenshtein.get(normalizedSingleTitle, normalizeTitle(title)); + + if (distance < smallestDistance && distance < normalizedSingleTitle.length / 2) { + smallestDistance = distance; + bestFitMedia = m; + } + }); + }); + + return bestFitMedia as Media | null; +}; + export const injectAiringTime = (anime: Media, subsPlease: SubsPlease | null) => { const airingAt = anime.nextAiringEpisode?.airingAt; // const nativeUntilAiring = airingAt diff --git a/src/lib/Media/Anime/season.ts b/src/lib/Media/Anime/season.ts new file mode 100644 index 00000000..d7922e2b --- /dev/null +++ b/src/lib/Media/Anime/season.ts @@ -0,0 +1,11 @@ +export const season = () => { + if (new Date().getMonth() >= 0 && new Date().getMonth() <= 2) + return 'WINTER' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; + else if (new Date().getMonth() >= 3 && new Date().getMonth() <= 5) + return 'SPRING' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; + else if (new Date().getMonth() >= 6 && new Date().getMonth() <= 8) + return 'SUMMER' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; + else if (new Date().getMonth() >= 9 && new Date().getMonth() <= 11) + return 'FALL' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; + else return 'WINTER' as 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; +}; diff --git a/src/routes/schedule/+page.svelte b/src/routes/schedule/+page.svelte index c629f820..2a975c46 100644 --- a/src/routes/schedule/+page.svelte +++ b/src/routes/schedule/+page.svelte @@ -5,8 +5,14 @@ import settings from '../../stores/settings'; import { parseOrDefault } from '$lib/Tools/tool'; import { browser } from '$app/environment'; + import type { Media } from '$lib/AniList/media'; + import { scheduleMediaListCollection } from '$lib/AniList/schedule'; + import { season } from '$lib/Media/Anime/season'; + import { findClosestMedia } from '$lib/Media/Anime/airing'; + import MediaTitleDisplay from '$lib/List/MediaTitleDisplay.svelte'; let subsPleasePromise: Promise; + let scheduledMediaPromise: Promise>; const urlParameters = browser ? new URLSearchParams(window.location.search) : null; let timeZone = parseOrDefault( urlParameters, @@ -14,11 +20,13 @@ Intl.DateTimeFormat().resolvedOptions().timeZone ); - onMount( - async () => (subsPleasePromise = fetch(`/api/subsplease?tz=${timeZone}`).then((r) => r.json())) - ); + onMount(async () => { + subsPleasePromise = fetch(`/api/subsplease?tz=${timeZone}`).then((r) => r.json()); + scheduledMediaPromise = scheduleMediaListCollection(new Date().getFullYear(), season()); + }); let hoveredItem: SubsPleaseEpisode | null = null; + let hoveredMedia: Media | null = null; let imageStyle = ''; let imageHeight = 0; @@ -58,67 +66,94 @@ return shiftedSchedule; }; + + const associateMedia = (media: (Media | undefined)[], title: string) => + findClosestMedia(media as Media[], title); {#await subsPleasePromise} Loading ... {:then subsPlease} {#if subsPlease} -
- {timeZone.split('/').reverse().join(', ').replace(/_/g, ' ')} -
- -

- -

- -
- {#each Object.entries(shiftSubsPleaseSchedule(subsPlease.schedule)) as [day, scheduleEntry]} -
- {day} - -
    - {#each Object.values(scheduleEntry) as entry} -
  • (hoveredItem = entry)} - on:mouseleave={() => (hoveredItem = null)} - on:mousemove={onMouseMove} - > - - {entry.title} - - {#if !$settings.displayCountdownRightAligned} - | - {/if} - - {entry.time} - -
  • + {#await scheduledMediaPromise} + Loading ... + {:then scheduledMedia} + {#if scheduledMedia} +
    + {timeZone.split('/').reverse().join(', ').replace(/_/g, ' ')} +
    + +

    + +

    + +
    + {#each Object.entries(shiftSubsPleaseSchedule(subsPlease.schedule)) as [day, scheduleEntry]} +
    + {day} + +
      + {#each Object.values(scheduleEntry) as entry} + {@const media = associateMedia(scheduledMedia, entry.title)} + +
    • { + hoveredItem = entry; + hoveredMedia = media; + }} + on:mouseleave={() => { + hoveredItem = null; + hoveredMedia = null; + }} + on:mousemove={onMouseMove} + > + + {#if media} + + {:else} + {entry.title} + {/if} + + {#if !$settings.displayCountdownRightAligned} + | + {/if} + + {entry.time} + +
    • + {/each} +
    + +

    +

    + {/each} +
    + {:else} + Loading ... + {/if} + {:catch} + + {/await} {:else} Loading ... {/if} @@ -129,7 +164,9 @@ {#if hoveredItem} Media Cover -- cgit v1.2.3