import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; import { DurableObject } from "cloudflare:workers"; import { print } from "graphql"; import { z } from "zod"; import { BrowsePopularQuery, GetNextEpisodeAiringAtQuery, GetPopularTitlesQuery, GetTitleQuery, GetTrendingTitlesQuery, GetUpcomingTitlesQuery, GetUserProfileQuery, GetUserQuery, MarkEpisodeAsWatchedMutation, MarkTitleAsWatchedMutation, NextSeasonPopularQuery, SearchQuery, } from "~/libs/anilist/queries"; import { sleep } from "~/libs/sleep.ts"; const nextAiringEpisodeSchema = z.nullable( z.object({ episode: z.number().int(), airingAt: z.number().int(), timeUntilAiring: z.number().int(), }), ); export class AnilistDurableObject extends DurableObject { state: DurableObjectState; constructor(state: DurableObjectState, env: Env) { super(state, env); this.state = state; } async fetch(request: Request) { return new Response("Not found", { status: 404 }); } async getTitle(id: number, token?: string) { return this.handleCachedRequest( `title:${id}`, async () => { const anilistResponse = await this.fetchFromAnilist( GetTitleQuery, { id }, token, ); return anilistResponse?.Media ?? null; }, (media) => { if (!media) return undefined; // Cast to any to access fragment fields without unmasking const nextAiringEpisode = nextAiringEpisodeSchema.parse( (media as any)?.nextAiringEpisode, ); const airingAt = (nextAiringEpisode?.airingAt ?? 0) * 1000; if (airingAt) { return airingAt - Date.now(); } return undefined; }, ); } async getNextEpisodeAiringAt(id: number) { return this.handleCachedRequest( `next_airing:${id}`, async () => { const data = await this.fetchFromAnilist(GetNextEpisodeAiringAtQuery, { id, }); return data?.Media; }, 60 * 60 * 1000, ); } async search(query: string, page: number, limit: number) { return this.handleCachedRequest( `search:${JSON.stringify({ query, page, limit })}`, async () => { const data = await this.fetchFromAnilist(SearchQuery, { query, page, limit, }); return data?.Page; }, 60 * 60 * 1000, ); } async browsePopular( season: any, seasonYear: number, nextSeason: any, nextYear: number, limit: number, ) { return this.handleCachedRequest( `popular:${JSON.stringify({ season, seasonYear, nextSeason, nextYear, limit })}`, async () => { console.log(nextSeason, nextYear, print(BrowsePopularQuery)); return this.fetchFromAnilist(BrowsePopularQuery, { season, seasonYear, nextSeason, nextYear, limit, }); }, 24 * 60 * 60 * 1000, ); } async nextSeasonPopular(nextSeason: any, nextYear: number, limit: number) { return this.handleCachedRequest( `next_season:${JSON.stringify({ nextSeason, nextYear, limit })}`, async () => { return this.fetchFromAnilist(NextSeasonPopularQuery, { nextSeason, nextYear, limit, }); }, 24 * 60 * 60 * 1000, ); } async getPopularTitles( page: number, limit: number, season: any, seasonYear: number, ) { return this.handleCachedRequest( `popular:${JSON.stringify({ page, limit, season, seasonYear })}`, async () => { const data = await this.fetchFromAnilist(GetPopularTitlesQuery, { page, limit, season, seasonYear, }); return data?.Page; }, 24 * 60 * 60 * 1000, ); } async getTrendingTitles(page: number, limit: number) { return this.handleCachedRequest( `trending:${JSON.stringify({ page, limit })}`, async () => { const data = await this.fetchFromAnilist(GetTrendingTitlesQuery, { page, limit, }); return data?.Page; }, 24 * 60 * 60 * 1000, ); } async getUpcomingTitles( page: number, airingAtLowerBound: number, airingAtUpperBound: number, ) { return this.handleCachedRequest( `upcoming:${JSON.stringify({ page, airingAtLowerBound, airingAtUpperBound })}`, async () => { const data = await this.fetchFromAnilist(GetUpcomingTitlesQuery, { page, airingAtLowerBound, airingAtUpperBound, }); return data?.Page; }, 24 * 60 * 60 * 1000, ); } async getUser(token: string) { return this.handleCachedRequest( `user:${token}`, async () => { const data = await this.fetchFromAnilist(GetUserQuery, {}, token); return data?.Viewer; }, 60 * 60 * 24 * 30 * 1000, ); } async getUserProfile(token: string) { return this.handleCachedRequest( `user_profile:${token}`, async () => { const data = await this.fetchFromAnilist( GetUserProfileQuery, { token }, token, ); return data?.Viewer; }, 60 * 60 * 24 * 30 * 1000, ); } async markEpisodeAsWatched( titleId: number, episodeNumber: number, token: string, ) { const data = await this.fetchFromAnilist( MarkEpisodeAsWatchedMutation, { titleId, episodeNumber }, token, ); return data?.SaveMediaListEntry; } async markTitleAsWatched(titleId: number, token: string) { const data = await this.fetchFromAnilist( MarkTitleAsWatchedMutation, { titleId }, token, ); return data?.SaveMediaListEntry; } // Helper to handle caching logic async handleCachedRequest( key: string, fetcher: () => Promise, ttl?: number | ((data: T) => number | undefined), ) { const cache = await this.state.storage.get(key); console.debug(`Retrieving request ${key} from cache:`, cache != null); if (cache) { return cache as T; } const result = await fetcher(); await this.state.storage.put(key, result); const calculatedTtl = typeof ttl === "function" ? ttl(result) : ttl; if (calculatedTtl && calculatedTtl > 0) { const alarmTime = Date.now() + calculatedTtl; await this.state.storage.setAlarm(alarmTime); await this.state.storage.put(`alarm:${key}`, alarmTime); } return result; } async alarm() { const now = Date.now(); const alarms = await this.state.storage.list({ prefix: "alarm:" }); for (const [key, ttl] of Object.entries(alarms)) { if (now >= ttl) { // 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 fetchFromAnilist( query: TypedDocumentNode, variables: Variables, token?: string | undefined, ): Promise { const headers: any = { "Content-Type": "application/json", }; if (token) { 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(`${this.env.PROXY_URL}/proxy`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://graphql.anilist.co/", method: "POST", headers, // Pass the original headers here data: { query: queryString, variables, }, }), }); // 1. Handle Rate Limiting (429) if (response.status === 429) { const retryAfter = await response .json() .then(({ headers }) => new Headers(headers).get("Retry-After")); console.log("429, retrying in", retryAfter); await sleep(Number(retryAfter || 1) * 1000); // specific fallback or ensure logic return this.fetchFromAnilist(query, variables, token); } // 2. Handle HTTP Errors (like 404 or 500) if (!response.ok) { // If it is specifically a 404 Not Found HTTP status if (response.status === 404) { return undefined; } if (response.headers.get("Content-Type") === "application/json") { console.error(JSON.stringify(await response.json(), null, 2)); } else { console.error(await response.text()); } // Throw for other HTTP errors to be caught by caller throw new Error(`HTTP Error: ${response.status} ${response.statusText}`); } // 3. Parse JSON const result = (await response.json().then((json: any) => json.data)) as { data?: any; errors?: any[]; }; // 4. Handle GraphQL Specific Errors (Anilist might return 200 OK but include errors) if (result.errors && result.errors.length > 0) { const errorMessage = JSON.stringify(result.errors); if (errorMessage.includes("Not Found")) { return undefined; } throw new Error(`GraphQL Error: ${errorMessage}`); } return result.data ?? undefined; } }