Files
aniplay-api/src/libs/anilist/anilist-do.ts

380 lines
10 KiB
TypeScript

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,
GetTitleUserDataQuery,
GetTrendingTitlesQuery,
GetUpcomingTitlesQuery,
GetUserProfileQuery,
GetUserQuery,
MarkEpisodeAsWatchedMutation,
MarkTitleAsWatchedMutation,
NextSeasonPopularQuery,
SearchQuery,
} from "~/libs/anilist/queries";
import { sleep } from "~/libs/sleep.ts";
import type { Title } from "~/types/title";
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,
userId?: string,
token?: string,
): Promise<Title | null> {
const promises: Promise<any>[] = [
this.handleCachedRequest(
`title:${id}`,
async () => {
const anilistResponse = await this.fetchFromAnilist(GetTitleQuery, {
id,
});
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,
);
return nextAiringEpisode?.airingAt
? DateTime.fromMillis(nextAiringEpisode?.airingAt)
: undefined;
},
),
];
promises.push(
userId
? this.handleCachedRequest(
`title:${id}:${userId}`,
async () => {
const anilistResponse = await this.fetchFromAnilist(
GetTitleUserDataQuery,
{ id },
{ token },
);
return anilistResponse?.Media ?? null;
},
DateTime.now().plus({ days: 1 }),
)
: Promise.resolve({ mediaListEntry: null }),
);
return Promise.all(promises).then(([title, userTitle]) => ({
...title,
...userTitle,
}));
}
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<T>(
key: string,
fetcher: () => Promise<T>,
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<Result = any, Variables = any>(
query: TypedDocumentNode<Result, Variables>,
variables: Variables,
token?: string | undefined,
): Promise<Result> {
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;
}
}