From b1e46ad6eb5333e16e65863ded185b2ef7837a9b Mon Sep 17 00:00:00 2001 From: Rushil Perera Date: Sat, 29 Nov 2025 05:03:57 -0500 Subject: [PATCH] feat: Centralize Anilist GraphQL queries, generalize Durable Object for multiple operations with caching, and add new controllers for search, popular titles, user data, and episode tracking. --- .gitignore | 1 - src/controllers/auth/anilist/getUser.ts | 66 ++--- .../episodes/markEpisodeAsWatched/anilist.ts | 106 +++---- .../internal/upcoming-titles/anilist.ts | 70 ++--- src/controllers/popular/browse/anilist.ts | 198 +++++-------- src/controllers/popular/category/anilist.ts | 147 ++++------ src/controllers/search/anilist.ts | 89 +++--- src/libs/anilist/anilist-do.ts | 251 ++++++++++++++--- src/libs/anilist/getNextEpisodeAiringAt.ts | 59 ++-- src/libs/anilist/queries.ts | 259 ++++++++++++++++++ 10 files changed, 726 insertions(+), 520 deletions(-) create mode 100644 src/libs/anilist/queries.ts diff --git a/.gitignore b/.gitignore index 3d2ad7e..1dc8cb5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,4 @@ dist *.db *.db-* .env -src/libs/anilist/anilist-do.ts .idea/ChatHistory_schema_v3.xml diff --git a/src/controllers/auth/anilist/getUser.ts b/src/controllers/auth/anilist/getUser.ts index 32fa7e2..5d12ee9 100644 --- a/src/controllers/auth/anilist/getUser.ts +++ b/src/controllers/auth/anilist/getUser.ts @@ -1,51 +1,33 @@ -import { graphql } from "gql.tada"; -import { GraphQLClient } from "graphql-request"; +import { env } from "cloudflare:workers"; -import { sleep } from "~/libs/sleep"; import type { User } from "~/types/user"; -const GetUserQuery = graphql(` - query GetUser { - Viewer { - id - name - avatar { - medium - large - } - statistics { - anime { - minutesWatched - episodesWatched - count - meanScore - } - } - } - } -`); - export async function getUser(aniListToken: string): Promise { - const client = new GraphQLClient("https://graphql.anilist.co/"); + const durableObjectId = env.ANILIST_DO.idFromName("GLOBAL"); + const stub = env.ANILIST_DO.get(durableObjectId); - try { - const data = await client.request(GetUserQuery, undefined, { - Authorization: `Bearer ${aniListToken}`, - }); - return { - ...data?.Viewer, - statistics: { ...data?.Viewer?.statistics?.anime }, - }; - } catch (err) { - if (err.response?.status === 401) { + const response = await stub.fetch("http://anilist-do/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + operationName: "GetUser", + variables: { token: aniListToken }, + }), + }); + + if (!response.ok) { + if (response.status === 401) { return null; - } else if (err.response?.status === 429) { - console.log("429, retrying in", err.response.headers.get("Retry-After")); - return sleep( - Number(err.response.headers.get("Retry-After")!) * 1000, - ).then(() => getUser(aniListToken)); } - - throw err; + throw new Error(`Failed to fetch user: ${response.statusText}`); } + + const data = (await response.json()) as any; + + return { + ...data, + statistics: { ...data?.statistics?.anime }, + }; } diff --git a/src/controllers/episodes/markEpisodeAsWatched/anilist.ts b/src/controllers/episodes/markEpisodeAsWatched/anilist.ts index 021ac25..338bea8 100644 --- a/src/controllers/episodes/markEpisodeAsWatched/anilist.ts +++ b/src/controllers/episodes/markEpisodeAsWatched/anilist.ts @@ -1,55 +1,4 @@ -import { graphql } from "gql.tada"; -import { GraphQLClient } from "graphql-request"; - -const MarkEpisodeAsWatchedMutation = graphql(` - mutation MarkEpisodeAsWatched($titleId: Int!, $episodeNumber: Int!) { - SaveMediaListEntry( - mediaId: $titleId - status: CURRENT - progress: $episodeNumber - ) { - user { - id - name - avatar { - medium - large - } - statistics { - anime { - minutesWatched - episodesWatched - count - meanScore - } - } - } - } - } -`); - -const MarkTitleAsWatchedMutation = graphql(` - mutation MarkTitleAsWatched($titleId: Int!) { - SaveMediaListEntry(mediaId: $titleId, status: COMPLETED) { - user { - id - name - avatar { - medium - large - } - statistics { - anime { - minutesWatched - episodesWatched - count - meanScore - } - } - } - } - } -`); +import { env } from "cloudflare:workers"; export async function markEpisodeAsWatched( aniListToken: string, @@ -57,27 +6,38 @@ export async function markEpisodeAsWatched( episodeNumber: number, markTitleAsComplete: boolean, ) { - const client = new GraphQLClient("https://graphql.anilist.co/"); + const durableObjectId = env.ANILIST_DO.idFromName("GLOBAL"); + const stub = env.ANILIST_DO.get(durableObjectId); - const mutation = markTitleAsComplete - ? client.request( - MarkTitleAsWatchedMutation, - { titleId }, - { Authorization: `Bearer ${aniListToken}` }, - ) - : client.request( - MarkEpisodeAsWatchedMutation, - { titleId, episodeNumber }, - { Authorization: `Bearer ${aniListToken}` }, - ); + const operationName = markTitleAsComplete + ? "MarkTitleAsWatched" + : "MarkEpisodeAsWatched"; - return mutation - .then((data) => ({ - ...data?.SaveMediaListEntry?.user, - statistics: data?.SaveMediaListEntry?.user?.statistics?.anime, - })) - .catch(async (err) => { - console.error(await err.response); - throw err; - }); + const variables = markTitleAsComplete + ? { titleId, token: aniListToken } + : { titleId, episodeNumber, token: aniListToken }; + + const response = await stub.fetch("http://anilist-do/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + operationName, + variables, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to mark episode as watched: ${response.statusText}`, + ); + } + + const data = (await response.json()) as any; + + return { + ...data?.user, + statistics: data?.user?.statistics?.anime, + }; } diff --git a/src/controllers/internal/upcoming-titles/anilist.ts b/src/controllers/internal/upcoming-titles/anilist.ts index 41f5673..1767e48 100644 --- a/src/controllers/internal/upcoming-titles/anilist.ts +++ b/src/controllers/internal/upcoming-titles/anilist.ts @@ -1,5 +1,4 @@ -import { graphql } from "gql.tada"; -import { GraphQLClient } from "graphql-request"; +import { env } from "cloudflare:workers"; import type { HonoRequest } from "hono"; import { DateTime } from "luxon"; @@ -7,38 +6,6 @@ import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEp import { getValue, setValue } from "~/models/kv"; import { filterUnreleasedTitles } from "~/models/unreleasedTitles"; import type { Title } from "~/types/title"; -import { MediaFragment } from "~/types/title/mediaFragment"; - -const GetUpcomingTitlesQuery = graphql( - ` - query GetUpcomingTitles( - $page: Int! - $airingAtLowerBound: Int! - $airingAtUpperBound: Int! - ) { - Page(page: $page) { - airingSchedules( - notYetAired: true - sort: TIME - airingAt_lesser: $airingAtUpperBound - airingAt_greater: $airingAtLowerBound - ) { - id - airingAt - timeUntilAiring - episode - media { - ...Media - } - } - pageInfo { - hasNextPage - } - } - } - `, - [MediaFragment], -); type AiringSchedule = { media: Title; @@ -49,7 +16,9 @@ type AiringSchedule = { }; export async function getUpcomingTitlesFromAnilist(req: HonoRequest) { - const client = new GraphQLClient("https://graphql.anilist.co/"); + const durableObjectId = env.ANILIST_DO.idFromName("GLOBAL"); + const stub = env.ANILIST_DO.get(durableObjectId); + const lastCheckedScheduleAt = await getValue("schedule_last_checked_at").then( (value) => (value ? Number(value) : DateTime.now().toUnixInteger()), ); @@ -61,21 +30,38 @@ export async function getUpcomingTitlesFromAnilist(req: HonoRequest) { let shouldContinue = true; do { - const { Page } = await client.request(GetUpcomingTitlesQuery, { - page: currentPage++, - airingAtLowerBound: lastCheckedScheduleAt, - airingAtUpperBound: twoDaysFromNow, + const response = await stub.fetch("http://anilist-do/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + operationName: "GetUpcomingTitles", + variables: { + page: currentPage++, + airingAtLowerBound: lastCheckedScheduleAt, + airingAtUpperBound: twoDaysFromNow, + }, + }), }); - const { airingSchedules, pageInfo } = Page!; + if (!response.ok) { + // If failed, break loop or handle error. For now, break. + break; + } + + const Page = (await response.json()) as any; + if (!Page) break; + + const { airingSchedules, pageInfo } = Page; plannedToWatchTitles = plannedToWatchTitles.union( await filterUnreleasedTitles( - airingSchedules!.map((schedule) => schedule!.media?.id!), + airingSchedules!.map((schedule: any) => schedule!.media?.id!), ), ); scheduleList = scheduleList.concat( airingSchedules!.filter( - (schedule): schedule is AiringSchedule => + (schedule: any): schedule is AiringSchedule => !!schedule && !plannedToWatchTitles.has(schedule.media?.id) && schedule.media?.countryOfOrigin === "JP" && diff --git a/src/controllers/popular/browse/anilist.ts b/src/controllers/popular/browse/anilist.ts index 2c6eeeb..1eefebb 100644 --- a/src/controllers/popular/browse/anilist.ts +++ b/src/controllers/popular/browse/anilist.ts @@ -1,157 +1,85 @@ -import { graphql } from "gql.tada"; -import { GraphQLClient } from "graphql-request"; +import { env } from "cloudflare:workers"; import { getCurrentAndNextSeason } from "~/libs/getCurrentAndNextSeason"; -import { sleep } from "~/libs/sleep"; -import { HomeTitleFragment } from "~/types/title/homeTitle"; import { mapTitle } from "../mapTitle"; -const BrowsePopularQuery = graphql( - ` - query BrowsePopular( - $season: MediaSeason! - $seasonYear: Int! - $nextSeason: MediaSeason! - $nextYear: Int! - $limit: Int! - ) { - trending: Page(page: 1, perPage: $limit) { - media(sort: TRENDING_DESC, type: ANIME, isAdult: false) { - ...HomeTitle - } - } - season: Page(page: 1, perPage: $limit) { - media( - season: $season - seasonYear: $seasonYear - sort: POPULARITY_DESC - type: ANIME - isAdult: false - ) { - ...HomeTitle - } - } - nextSeason: Page(page: 1, perPage: 1) { - media( - season: $nextSeason - seasonYear: $nextYear - sort: START_DATE_DESC - type: ANIME - isAdult: false - ) { - nextAiringEpisode { - airingAt - timeUntilAiring - } - } - } - } - `, - [HomeTitleFragment], -); - -const NextSeasonPopularQuery = graphql(` - query NextSeasonPopular( - $nextSeason: MediaSeason - $nextYear: Int - $limit: Int! - ) { - Page(page: 1, perPage: $limit) { - media( - season: $nextSeason - seasonYear: $nextYear - sort: POPULARITY_DESC - type: ANIME - isAdult: false - ) { - ...media - } - } - } - - fragment media on Media { - id - title { - english - userPreferred - } - coverImage { - extraLarge - large - medium - } - } -`); - export async function fetchPopularTitlesFromAnilist( limit: number, ): Promise { - const client = new GraphQLClient("https://graphql.anilist.co/"); + const durableObjectId = env.ANILIST_DO.idFromName("GLOBAL"); + const stub = env.ANILIST_DO.get(durableObjectId); + const { current: { season: currentSeason, year: currentYear }, next: { season: nextSeason, year: nextYear }, } = getCurrentAndNextSeason(); - try { - const data = await client.request(BrowsePopularQuery, { - limit, - season: currentSeason, - seasonYear: currentYear, - nextSeason, - nextYear, - }); - if (!data) return undefined; + const response = await stub.fetch("http://anilist-do/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + operationName: "BrowsePopular", + variables: { + limit, + season: currentSeason, + seasonYear: currentYear, + nextSeason, + nextYear, + }, + }), + }); - const trendingTitles = data.trending?.media?.map((title) => - mapTitle(title), - ); - const popularSeasonTitles = data.season?.media?.map((title) => - mapTitle(title), - ); + if (!response.ok) { + return undefined; + } - if (!data.nextSeason?.media?.[0]?.nextAiringEpisode) { - return { - trending: trendingTitles, - popular: popularSeasonTitles, - }; - } + const data = (await response.json()) as any; + if (!data) return undefined; - return await client - .request(NextSeasonPopularQuery, { + const trendingTitles = data.trending?.media?.map((title: any) => + mapTitle(title), + ); + const popularSeasonTitles = data.season?.media?.map((title: any) => + mapTitle(title), + ); + + if (!data.nextSeason?.media?.[0]?.nextAiringEpisode) { + return { + trending: trendingTitles, + popular: popularSeasonTitles, + }; + } + + const nextSeasonResponse = await stub.fetch("http://anilist-do/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + operationName: "NextSeasonPopular", + variables: { limit, nextSeason, nextYear, - }) - .then((data) => ({ - trending: trendingTitles, - popular: popularSeasonTitles, - upcoming: data?.Page?.media?.map((title) => mapTitle(title)), - })); - } catch (error) { - const response = error.response; - if (response.status === 429) { - console.log("429, retrying in", response.headers.get("Retry-After")); - return sleep(Number(response.headers.get("Retry-After")!) * 1000).then( - () => fetchPopularTitlesFromAnilist(limit), - ); - } + }, + }), + }); - throw error; + if (!nextSeasonResponse.ok) { + return { + trending: trendingTitles, + popular: popularSeasonTitles, + }; } -} -type SearchResultsResponse = { - results: - | ({ - id: number; - title: { userPreferred: string | null; english: string | null } | null; - coverImage: { - extraLarge: string | null; - large: string | null; - medium: string | null; - } | null; - } | null)[] - | null; - hasNextPage: boolean | null | undefined; -}; + const nextSeasonData = (await nextSeasonResponse.json()) as any; + + return { + trending: trendingTitles, + popular: popularSeasonTitles, + upcoming: nextSeasonData?.Page?.media?.map((title: any) => mapTitle(title)), + }; +} diff --git a/src/controllers/popular/category/anilist.ts b/src/controllers/popular/category/anilist.ts index 942ed9b..6f826cc 100644 --- a/src/controllers/popular/category/anilist.ts +++ b/src/controllers/popular/category/anilist.ts @@ -1,121 +1,66 @@ -import { graphql } from "gql.tada"; -import { GraphQLClient } from "graphql-request"; +import { env } from "cloudflare:workers"; import { getCurrentAndNextSeason } from "~/libs/getCurrentAndNextSeason"; -import { HomeTitleFragment } from "~/types/title/homeTitle"; import { mapTitle } from "../mapTitle"; import type { PopularCategory } from "./enum"; -const TrendingQuery = graphql( - ` - query Trending($limit: Int!, $page: Int!) { - trending: Page(page: $page, perPage: $limit) { - media(sort: TRENDING_DESC, type: ANIME, isAdult: false) { - ...HomeTitle - } - pageInfo { - hasNextPage - } - } - } - `, - [HomeTitleFragment], -); - -const PopularQuery = graphql( - ` - query Popular( - $limit: Int! - $page: Int! - $season: MediaSeason! - $seasonYear: Int! - ) { - Page(page: $page, perPage: $limit) { - media( - season: $season - seasonYear: $seasonYear - sort: POPULARITY_DESC - type: ANIME - isAdult: false - ) { - ...HomeTitle - } - pageInfo { - hasNextPage - } - } - } - `, - [HomeTitleFragment], -); - -const UpcomingQuery = graphql( - ` - query Upcoming( - $limit: Int! - $page: Int! - $nextSeason: MediaSeason! - $nextSeasonYear: Int! - ) { - Page(page: $page, perPage: $limit) { - media( - season: $nextSeason - seasonYear: $nextSeasonYear - sort: POPULARITY_DESC - type: ANIME - isAdult: false - ) { - ...HomeTitle - } - pageInfo { - hasNextPage - } - } - } - `, - [HomeTitleFragment], -); - -export function fetchPopularTitlesFromAnilist( +export async function fetchPopularTitlesFromAnilist( category: PopularCategory, page: number, limit: number, ) { - const client = new GraphQLClient("https://graphql.anilist.co/"); + const durableObjectId = env.ANILIST_DO.idFromName("GLOBAL"); + const stub = env.ANILIST_DO.get(durableObjectId); const { current, next } = getCurrentAndNextSeason(); + + let operationName = ""; + let variables: any = { limit, page }; + switch (category) { case "trending": - return client.request(TrendingQuery, { limit, page }).then((data) => ({ - results: data?.trending?.media?.map((title) => mapTitle(title)), - hasNextPage: data?.trending?.pageInfo?.hasNextPage, - })); + operationName = "GetTrendingTitles"; + break; case "popular": - return client - .request(PopularQuery, { - limit, - page, - season: current.season, - seasonYear: current.year, - }) - .then((data) => ({ - results: data?.Page?.media?.map((title) => mapTitle(title)), - hasNextPage: data?.Page?.pageInfo?.hasNextPage, - })); + operationName = "GetPopularTitles"; + variables = { + ...variables, + season: current.season, + seasonYear: current.year, + }; + break; case "upcoming": - return client - .request(UpcomingQuery, { - limit, - page, - nextSeason: next.season, - nextSeasonYear: next.year, - }) - .then((data) => ({ - results: data?.Page?.media?.map((title) => mapTitle(title)), - hasNextPage: data?.Page?.pageInfo?.hasNextPage, - })); + operationName = "NextSeasonPopular"; + variables = { + ...variables, + nextSeason: next.season, + nextYear: next.year, + }; + break; default: throw new Error(`Unknown category: ${category}`); } + + const response = await stub.fetch("http://anilist-do/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + operationName, + variables, + }), + }); + + if (!response.ok) { + return { results: [], hasNextPage: false }; + } + + const data = (await response.json()) as any; + + return { + results: data?.media?.map((title: any) => mapTitle(title)), + hasNextPage: data?.pageInfo?.hasNextPage, + }; } diff --git a/src/controllers/search/anilist.ts b/src/controllers/search/anilist.ts index 921f208..f2ee7fa 100644 --- a/src/controllers/search/anilist.ts +++ b/src/controllers/search/anilist.ts @@ -1,69 +1,46 @@ -import { graphql } from "gql.tada"; -import { GraphQLClient } from "graphql-request"; - -import { sleep } from "~/libs/sleep"; -import { HomeTitleFragment } from "~/types/title/homeTitle"; - -const SearchQuery = graphql( - ` - query Search($query: String!, $page: Int!, $limit: Int!) { - Page(page: $page, perPage: $limit) { - media( - search: $query - type: ANIME - sort: [POPULARITY_DESC, SCORE_DESC] - ) { - ...HomeTitle - } - pageInfo { - hasNextPage - } - } - } - `, - [HomeTitleFragment], -); +import { env } from "cloudflare:workers"; export async function fetchSearchResultsFromAnilist( query: string, page: number, limit: number, ): Promise { - const client = new GraphQLClient("https://graphql.anilist.co/"); + const durableObjectId = env.ANILIST_DO.idFromName("GLOBAL"); + const stub = env.ANILIST_DO.get(durableObjectId); - return client - .request(SearchQuery, { page, query, limit }) - .then((data) => data?.Page) - .then((page) => { - if (!page || page.media?.length === 0) { - return undefined; - } + const response = await stub.fetch("http://anilist-do/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + operationName: "Search", + variables: { query, page, limit }, + }), + }); + + if (!response.ok) { + return undefined; + } + + const data = (await response.json()) as any; + if (!data || data.media?.length === 0) { + return undefined; + } + + const { media: results, pageInfo } = data; + return { + results: results?.map((result: any) => { + if (!result) return null; - const { media: results, pageInfo } = page; return { - results: results?.map((result) => { - if (!result) return null; - - return { - id: result.id, - title: result.title?.userPreferred ?? result.title?.english, - coverImage: result.coverImage, - }; - }), - hasNextPage: pageInfo?.hasNextPage, + id: result.id, + title: result.title?.userPreferred ?? result.title?.english, + coverImage: result.coverImage, }; - }) - .catch((err) => { - const response = err.response; - if (response.status === 429) { - console.log("429, retrying in", response.headers.get("Retry-After")); - return sleep(Number(response.headers.get("Retry-After")!) * 1000).then( - () => fetchSearchResultsFromAnilist(query, page, limit), - ); - } - - throw err; - }); + }), + hasNextPage: pageInfo?.hasNextPage, + }; } type SearchResultsResponse = { diff --git a/src/libs/anilist/anilist-do.ts b/src/libs/anilist/anilist-do.ts index 923acf2..ab9ea54 100644 --- a/src/libs/anilist/anilist-do.ts +++ b/src/libs/anilist/anilist-do.ts @@ -1,22 +1,22 @@ import { DurableObject, env } from "cloudflare:workers"; -import { graphql, type ResultOf } from "gql.tada"; +import { type ResultOf } from "gql.tada"; import { print } from "graphql"; import { z } from "zod"; +import { + BrowsePopularQuery, + GetNextEpisodeAiringAtQuery, + GetPopularTitlesQuery, + GetTitleQuery, + GetTrendingTitlesQuery, + GetUpcomingTitlesQuery, + GetUserQuery, + MarkEpisodeAsWatchedMutation, + MarkTitleAsWatchedMutation, + NextSeasonPopularQuery, + SearchQuery, +} from "~/libs/anilist/queries"; import { sleep } from "~/libs/sleep.ts"; -import type { Title } from "~/types/title"; -import { MediaFragment } from "~/types/title/mediaFragment"; - -const GetTitleQuery = graphql( - ` - query GetTitle($id: Int!) { - Media(id: $id) { - ...Media - } - } - `, - [MediaFragment], -); const nextAiringEpisodeSchema = z.nullable( z.object({ @@ -35,12 +35,38 @@ export class AnilistDurableObject extends DurableObject { } async fetch(request: Request) { - const body = await request.json(); - const { operationName } = body; + const body = (await request.json()) as any; + const { operationName, variables } = body; + + // Helper to handle caching logic + const handleCachedRequest = async ( + key: string, + fetcher: () => Promise, + ttl?: number, + ) => { + const cache = await this.state.storage.get(key); + if (cache) { + return new Response(JSON.stringify(cache), { + headers: { "Content-Type": "application/json" }, + }); + } + + const result = await fetcher(); + await this.state.storage.put(key, result); + + if (ttl) { + const alarmTime = Date.now() + ttl; + await this.state.storage.setAlarm(alarmTime); + await this.state.storage.put(`alarm:${key}`, alarmTime); + } + + return new Response(JSON.stringify(result), { + headers: { "Content-Type": "application/json" }, + }); + }; switch (operationName) { case "GetTitle": { - const { variables } = body; const storageKey = variables.id; const cache = await this.state.storage.get(storageKey); if (cache) { @@ -49,25 +75,173 @@ export class AnilistDurableObject extends DurableObject { }); } - const anilistResponse = await this.fetchTitleFromAnilist( - variables.id, + const anilistResponse = await this.fetchFromAnilist( + GetTitleQuery, + variables, variables.token, ); + + // Extract next airing episode for alarm + // We need to cast or check the response structure because fetchFromAnilist returns generic data + const media = anilistResponse.Media as ResultOf< + typeof GetTitleQuery + >["Media"]; + + // Cast to any to access fragment fields without unmasking const nextAiringEpisode = nextAiringEpisodeSchema.parse( - anilistResponse?.nextAiringEpisode, + (media as any)?.nextAiringEpisode, ); const airingAt = (nextAiringEpisode?.airingAt ?? 0) * 1000; - await this.state.storage.put(storageKey, anilistResponse); + await this.state.storage.put(storageKey, media); if (airingAt) { await this.state.storage.setAlarm(airingAt); await this.state.storage.put(`alarm:${variables.id}`, airingAt); } - return new Response(JSON.stringify(anilistResponse), { + return new Response(JSON.stringify(media), { headers: { "Content-Type": "application/json" }, }); } + + case "GetNextEpisodeAiringAt": { + const storageKey = `next_airing:${variables.id}`; + // Cache for 1 hour or until airing? + // For now, let's cache for 1 hour as it might change + const TTL = 60 * 60 * 1000; + + return handleCachedRequest( + storageKey, + async () => { + const data = await this.fetchFromAnilist( + GetNextEpisodeAiringAtQuery, + variables, + ); + return data?.Media; + }, + TTL, + ); + } + + case "Search": { + const storageKey = `search:${JSON.stringify(variables)}`; + const TTL = 60 * 60 * 1000; // 1 hour + return handleCachedRequest( + storageKey, + async () => { + const data = await this.fetchFromAnilist(SearchQuery, variables); + return data?.Page; + }, + TTL, + ); + } + + case "BrowsePopular": { + const storageKey = `browse_popular:${JSON.stringify(variables)}`; + const TTL = 60 * 60 * 1000; // 1 hour + return handleCachedRequest( + storageKey, + async () => { + return this.fetchFromAnilist(BrowsePopularQuery, variables); + }, + TTL, + ); + } + + case "NextSeasonPopular": { + const storageKey = `next_season:${JSON.stringify(variables)}`; + const TTL = 60 * 60 * 1000; + return handleCachedRequest( + storageKey, + async () => { + return this.fetchFromAnilist(NextSeasonPopularQuery, variables); + }, + TTL, + ); + } + + case "GetPopularTitles": { + const storageKey = `popular:${JSON.stringify(variables)}`; + const TTL = 60 * 60 * 1000; + return handleCachedRequest( + storageKey, + async () => { + const data = await this.fetchFromAnilist( + GetPopularTitlesQuery, + variables, + ); + return data?.Page; + }, + TTL, + ); + } + + case "GetTrendingTitles": { + const storageKey = `trending:${JSON.stringify(variables)}`; + const TTL = 60 * 60 * 1000; + return handleCachedRequest( + storageKey, + async () => { + const data = await this.fetchFromAnilist( + GetTrendingTitlesQuery, + variables, + ); + return data?.Page; + }, + TTL, + ); + } + + case "GetUser": { + // No caching for user data for now, just rate limiting via DO + const data = await this.fetchFromAnilist( + GetUserQuery, + variables, + variables.token, + ); + return new Response(JSON.stringify(data?.Viewer), { + headers: { "Content-Type": "application/json" }, + }); + } + + case "MarkEpisodeAsWatched": { + const data = await this.fetchFromAnilist( + MarkEpisodeAsWatchedMutation, + variables, + variables.token, + ); + return new Response(JSON.stringify(data?.SaveMediaListEntry), { + headers: { "Content-Type": "application/json" }, + }); + } + + case "MarkTitleAsWatched": { + const data = await this.fetchFromAnilist( + MarkTitleAsWatchedMutation, + variables, + variables.token, + ); + return new Response(JSON.stringify(data?.SaveMediaListEntry), { + headers: { "Content-Type": "application/json" }, + }); + } + + case "GetUpcomingTitles": { + const storageKey = `upcoming:${JSON.stringify(variables)}`; + const TTL = 60 * 60 * 1000; + return handleCachedRequest( + storageKey, + async () => { + const data = await this.fetchFromAnilist( + GetUpcomingTitlesQuery, + variables, + ); + return data?.Page; + }, + TTL, + ); + } + default: return new Response("Not found", { status: 404 }); } @@ -76,17 +250,22 @@ export class AnilistDurableObject extends DurableObject { async alarm() { const now = Date.now(); const alarms = await this.state.storage.list({ prefix: "alarm:" }); - for (const [id, ttl] of Object.entries(alarms)) { + for (const [key, ttl] of Object.entries(alarms)) { if (now >= ttl) { - await this.state.storage.delete(id); + // The key in alarms is `alarm:${storageKey}` + // We want to delete the storageKey + const storageKey = key.replace("alarm:", ""); + await this.state.storage.delete(storageKey); + await this.state.storage.delete(key); } } } - async fetchTitleFromAnilist( - id: number, + async fetchFromAnilist( + query: any, + variables: any, token?: string | undefined, - ): Promise { + ): Promise<any> { const headers: any = { "Content-Type": "application/json", }; @@ -95,6 +274,10 @@ export class AnilistDurableObject extends DurableObject { headers["Authorization"] = `Bearer ${token}`; } + // Use the query passed in, or fallback if needed (though we expect it to be passed) + // We print the query to string + const queryString = print(query); + const response = await fetch(`${env.PROXY_URL}/proxy`, { method: "POST", headers: { @@ -105,9 +288,8 @@ export class AnilistDurableObject extends DurableObject { method: "POST", headers, // Pass the original headers here data: { - operationName: "GetTitle", - query: print(GetTitleQuery), - variables: { id }, + query: queryString, + variables, }, }), }); @@ -118,7 +300,7 @@ export class AnilistDurableObject extends DurableObject { console.log("429, retrying in", retryAfter); await sleep(Number(retryAfter || 1) * 1000); // specific fallback or ensure logic - return this.fetchTitleFromAnilist(id, token); + return this.fetchFromAnilist(query, variables, token); } // 2. Handle HTTP Errors (like 404 or 500) @@ -134,9 +316,8 @@ export class AnilistDurableObject extends DurableObject { } // 3. Parse JSON - // We cast this to ResultOf<typeof GetTitleQuery> to maintain Tada type safety - const result = (await response.json().then((json) => json.data)) as { - data?: ResultOf<typeof GetTitleQuery>; + const result = (await response.json().then((json: any) => json.data)) as { + data?: any; errors?: any[]; }; @@ -151,6 +332,6 @@ export class AnilistDurableObject extends DurableObject { throw new Error(`GraphQL Error: ${errorMessage}`); } - return result.data?.Media ?? undefined; + return result.data ?? undefined; } } diff --git a/src/libs/anilist/getNextEpisodeAiringAt.ts b/src/libs/anilist/getNextEpisodeAiringAt.ts index bd0fa39..ce1b922 100644 --- a/src/libs/anilist/getNextEpisodeAiringAt.ts +++ b/src/libs/anilist/getNextEpisodeAiringAt.ts @@ -1,19 +1,4 @@ -import { graphql } from "gql.tada"; -import { GraphQLClient } from "graphql-request"; - -import { sleep } from "../sleep"; - -const GetNextEpisodeAiringAtQuery = graphql(` - query GetNextEpisodeAiringAt($id: Int!) { - Media(id: $id) { - status - nextAiringEpisode { - episode - airingAt - } - } - } -`); +import { env } from "cloudflare:workers"; type NextAiringTime = { status: @@ -32,26 +17,30 @@ type NextAiringTime = { export async function getNextEpisodeTimeUntilAiring( aniListId: number, ): Promise<NextAiringTime> { - const client = new GraphQLClient("https://graphql.anilist.co/"); + const durableObjectId = env.ANILIST_DO.idFromName(aniListId.toString()); + const stub = env.ANILIST_DO.get(durableObjectId); - try { - const { status, nextAiringEpisode: nextAiring } = await client - .request(GetNextEpisodeAiringAtQuery, { - id: aniListId, - }) - .then((data) => data!.Media!); + const response = await stub.fetch("http://anilist-do/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + operationName: "GetNextEpisodeAiringAt", + variables: { id: aniListId }, + }), + }); - return { status, nextAiring }; - } catch (error) { - if (error.response.status === 429) { - console.log( - "429, retrying in", - error.response.headers.get("Retry-After"), - ); - await sleep(Number(error.response.headers.get("Retry-After")!) * 1000); - return getNextEpisodeTimeUntilAiring(aniListId); - } - - throw error; + if (!response.ok) { + throw new Error( + `Failed to fetch next episode airing time: ${response.statusText}`, + ); } + + const data = (await response.json()) as any; + + return { + status: data?.status, + nextAiring: data?.nextAiringEpisode, + }; } diff --git a/src/libs/anilist/queries.ts b/src/libs/anilist/queries.ts new file mode 100644 index 0000000..ec8af6a --- /dev/null +++ b/src/libs/anilist/queries.ts @@ -0,0 +1,259 @@ +import { graphql } from "gql.tada"; + +import { HomeTitleFragment } from "~/types/title/homeTitle"; +import { MediaFragment } from "~/types/title/mediaFragment"; + +export const GetTitleQuery = graphql( + ` + query GetTitle($id: Int!) { + Media(id: $id) { + ...Media + } + } + `, + [MediaFragment], +); + +export const SearchQuery = graphql( + ` + query Search($query: String!, $page: Int!, $limit: Int!) { + Page(page: $page, perPage: $limit) { + media( + search: $query + type: ANIME + sort: [POPULARITY_DESC, SCORE_DESC] + ) { + ...HomeTitle + } + pageInfo { + hasNextPage + } + } + } + `, + [HomeTitleFragment], +); + +export const GetNextEpisodeAiringAtQuery = graphql(` + query GetNextEpisodeAiringAt($id: Int!) { + Media(id: $id) { + status + nextAiringEpisode { + episode + airingAt + } + } + } +`); + +export const MarkEpisodeAsWatchedMutation = graphql(` + mutation MarkEpisodeAsWatched($titleId: Int!, $episodeNumber: Int!) { + SaveMediaListEntry( + mediaId: $titleId + status: CURRENT + progress: $episodeNumber + ) { + user { + id + name + avatar { + medium + large + } + statistics { + anime { + minutesWatched + episodesWatched + count + meanScore + } + } + } + } + } +`); + +export const MarkTitleAsWatchedMutation = graphql(` + mutation MarkTitleAsWatched($titleId: Int!) { + SaveMediaListEntry(mediaId: $titleId, status: COMPLETED) { + user { + id + name + avatar { + medium + large + } + statistics { + anime { + minutesWatched + episodesWatched + count + meanScore + } + } + } + } + } +`); + +export const GetUserQuery = graphql(` + query GetUser { + Viewer { + id + name + avatar { + medium + large + } + statistics { + anime { + minutesWatched + episodesWatched + count + meanScore + } + } + } + } +`); + +export const GetPopularTitlesQuery = graphql( + ` + query GetPopularTitles( + $page: Int + $limit: Int + $season: MediaSeason + $seasonYear: Int + ) { + Page(page: $page, perPage: $limit) { + media( + type: ANIME + sort: POPULARITY_DESC + season: $season + seasonYear: $seasonYear + isAdult: false + ) { + ...HomeTitle + } + pageInfo { + hasNextPage + } + } + } + `, + [HomeTitleFragment], +); + +export const GetTrendingTitlesQuery = graphql( + ` + query GetTrendingTitles($page: Int, $limit: Int) { + Page(page: $page, perPage: $limit) { + media(type: ANIME, sort: TRENDING_DESC, isAdult: false) { + ...HomeTitle + } + pageInfo { + hasNextPage + } + } + } + `, + [HomeTitleFragment], +); + +export const GetUpcomingTitlesQuery = graphql( + ` + query GetUpcomingTitles( + $page: Int! + $airingAtLowerBound: Int! + $airingAtUpperBound: Int! + ) { + Page(page: $page) { + airingSchedules( + notYetAired: true + sort: TIME + airingAt_lesser: $airingAtUpperBound + airingAt_greater: $airingAtLowerBound + ) { + id + airingAt + timeUntilAiring + episode + media { + ...Media + } + } + pageInfo { + hasNextPage + } + } + } + `, + [MediaFragment], +); + +export const BrowsePopularQuery = graphql( + ` + query BrowsePopular( + $season: MediaSeason! + $seasonYear: Int! + $nextSeason: MediaSeason! + $nextYear: Int! + $limit: Int! + ) { + trending: Page(page: 1, perPage: $limit) { + media(sort: TRENDING_DESC, type: ANIME, isAdult: false) { + ...HomeTitle + } + } + season: Page(page: 1, perPage: $limit) { + media( + season: $season + seasonYear: $seasonYear + sort: POPULARITY_DESC + type: ANIME + isAdult: false + ) { + ...HomeTitle + } + } + nextSeason: Page(page: 1, perPage: 1) { + media( + season: $nextSeason + seasonYear: $nextYear + sort: START_DATE_DESC + type: ANIME + isAdult: false + ) { + nextAiringEpisode { + airingAt + timeUntilAiring + } + } + } + } + `, + [HomeTitleFragment], +); + +export const NextSeasonPopularQuery = graphql( + ` + query NextSeasonPopular( + $nextSeason: MediaSeason + $nextYear: Int + $limit: Int! + ) { + Page(page: 1, perPage: $limit) { + media( + season: $nextSeason + seasonYear: $nextYear + sort: POPULARITY_DESC + type: ANIME + isAdult: false + ) { + ...HomeTitle + } + } + } + `, + [HomeTitleFragment], +);