354 lines
9.3 KiB
TypeScript
354 lines
9.3 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,
|
|
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<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;
|
|
}
|
|
}
|