feat: Add durable object for Anilist requests (#2)

This commit introduces a durable object to handle Anilist GraphQL requests, with the goal of preventing request duplication.

The durable object is designed to cache responses from the Anilist API, with a TTL based on the `nextAiringEpisode` field in the response.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
2025-11-14 13:07:36 +08:00
committed by GitHub
parent f6a3ea2649
commit 959265484c
6 changed files with 1258 additions and 133 deletions

View File

@@ -67,9 +67,4 @@ app.get("/docs", swaggerUI({ url: "/openapi.json" }));
export default app;
export class AniwatchApiContainer /* extends Container */ {
// Port the container listens on (default: 8080)
defaultPort = 4444;
// Time before container sleeps due to inactivity (default: 30s)
sleepAfter = "2m";
}
export { AnilistDurableObject as AnilistDo } from "~/libs/anilist/anilist-do.ts";

View File

@@ -0,0 +1,104 @@
import { DurableObject, env } from "cloudflare:workers";
import { GraphQLClient } from "graphql-request";
import { z } from "zod";
import { GetTitleQuery, _fetchTitleFromAnilist } from "~/libs/anilist/getTitle";
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) {
super(state, env);
this.state = state;
}
async fetch(request: Request) {
const body = await request.json();
const { operationName } = body;
switch (operationName) {
case "GetTitle": {
const { variables } = body;
const cache = await this.state.storage.get(variables.id);
if (cache) {
return new Response(JSON.stringify(cache), {
headers: { "Content-Type": "application/json" },
});
}
const anilistResponse = await this.fetchTitleFromAnilist(
variables.id,
variables.token,
);
const nextAiringEpisode = nextAiringEpisodeSchema.parse(
anilistResponse?.nextAiringEpisode,
);
const airingAt = (nextAiringEpisode?.airingAt ?? 0) * 1000;
await this.state.storage.put(variables.id, anilistResponse);
if (airingAt) {
await this.state.storage.setAlarm(airingAt);
await this.state.storage.put(`alarm:${variables.id}`, airingAt);
}
return new Response(JSON.stringify(anilistResponse), {
headers: { "Content-Type": "application/json" },
});
}
default:
return new Response("Not found", { status: 404 });
}
}
async alarm() {
const now = Date.now();
const alarms = await this.state.storage.list({ prefix: "alarm:" });
console.log("alarm", now, alarms);
for (const [id, ttl] of Object.entries(alarms)) {
if (now >= ttl) {
await this.state.storage.delete(id);
}
}
}
async fetchTitleFromAnilist(
id: number,
token?: string | undefined,
): Promise<Title | undefined> {
const client = new GraphQLClient("https://graphql.anilist.co/");
const headers = new Headers();
if (token) {
headers.append("Authorization", `Bearer ${token}`);
}
return client
.request(GetTitleQuery, { id }, headers)
.then((data) => data?.Media ?? undefined)
.catch((error) => {
if (error.message.includes("Not Found")) {
return undefined;
}
if (error.response?.status === 429) {
console.log(
"429, retrying in",
error.response.headers.get("Retry-After"),
);
return sleep(
Number(error.response.headers.get("Retry-After")!) * 1000,
).then(() => this.fetchTitleFromAnilist(id, token));
}
throw error;
});
}
}

View File

@@ -1,12 +1,10 @@
import { env } from "cloudflare:workers";
import { graphql } from "gql.tada";
import { GraphQLClient } from "graphql-request";
import type { Title } from "~/types/title";
import { MediaFragment } from "~/types/title/mediaFragment";
import { sleep } from "../sleep";
const GetTitleQuery = graphql(
export const GetTitleQuery = graphql(
`
query GetTitle($id: Int!) {
Media(id: $id) {
@@ -21,29 +19,30 @@ export async function fetchTitleFromAnilist(
id: number,
token?: string | undefined,
): Promise<Title | undefined> {
const client = new GraphQLClient("https://graphql.anilist.co/");
const headers = new Headers();
if (token) {
headers.append("Authorization", `Bearer ${token}`);
const durableObjectId = env.ANILIST_DO.idFromName(
id.toString() + (token == null ? "" : "_" + token),
);
const stub = env.ANILIST_DO.get(durableObjectId);
const response = await stub.fetch("http://anilist-do/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
operationName: "GetTitle",
variables: { id, token },
}),
});
if (!response.ok || response.status === 204) {
return undefined;
}
return client
.request(GetTitleQuery, { id }, headers)
.then((data) => data?.Media ?? undefined)
.catch((error) => {
if (error.message.includes("Not Found")) {
return undefined;
}
if (error.response?.status === 429) {
console.log(
"429, retrying in",
error.response.headers.get("Retry-After"),
);
return sleep(
Number(error.response.headers.get("Retry-After")!) * 1000,
).then(() => fetchTitleFromAnilist(id, token));
}
const text = await response.text();
if (!text) {
return undefined;
}
throw error;
});
return JSON.parse(text);
}

View File

@@ -3,20 +3,7 @@ import * as gqlTada from "gql.tada";
/* eslint-disable */
/* prettier-ignore */
/** An IntrospectionQuery representation of your schema.
*
* @remarks
* This is an introspection of your schema saved as a file by GraphQLSP.
* It will automatically be used by `gql.tada` to infer the types of your GraphQL documents.
* If you need to reuse this data or update your `scalars`, update `tadaOutputLocation` to
* instead save to a .ts instead of a .d.ts file.
*/
export type introspection = {
name: never;
query: 'Query';
mutation: 'Mutation';
subscription: never;
types: {
export type introspection_types = {
'ActivityLikeNotification': { kind: 'OBJECT'; name: 'ActivityLikeNotification'; fields: { 'activity': { name: 'activity'; type: { kind: 'UNION'; name: 'ActivityUnion'; ofType: null; } }; 'activityId': { name: 'activityId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'context': { name: 'context'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'type': { name: 'type'; type: { kind: 'ENUM'; name: 'NotificationType'; ofType: null; } }; 'user': { name: 'user'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'userId': { name: 'userId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; }; };
'ActivityMentionNotification': { kind: 'OBJECT'; name: 'ActivityMentionNotification'; fields: { 'activity': { name: 'activity'; type: { kind: 'UNION'; name: 'ActivityUnion'; ofType: null; } }; 'activityId': { name: 'activityId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'context': { name: 'context'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'type': { name: 'type'; type: { kind: 'ENUM'; name: 'NotificationType'; ofType: null; } }; 'user': { name: 'user'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'userId': { name: 'userId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; }; };
'ActivityMessageNotification': { kind: 'OBJECT'; name: 'ActivityMessageNotification'; fields: { 'activityId': { name: 'activityId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'context': { name: 'context'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'message': { name: 'message'; type: { kind: 'OBJECT'; name: 'MessageActivity'; ofType: null; } }; 'type': { name: 'type'; type: { kind: 'ENUM'; name: 'NotificationType'; ofType: null; } }; 'user': { name: 'user'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'userId': { name: 'userId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; }; };
@@ -202,7 +189,22 @@ export type introspection = {
'UserTitleLanguage': { name: 'UserTitleLanguage'; enumValues: 'ROMAJI' | 'ENGLISH' | 'NATIVE' | 'ROMAJI_STYLISED' | 'ENGLISH_STYLISED' | 'NATIVE_STYLISED'; };
'UserVoiceActorStatistic': { kind: 'OBJECT'; name: 'UserVoiceActorStatistic'; fields: { 'chaptersRead': { name: 'chaptersRead'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'characterIds': { name: 'characterIds'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; }; } }; 'count': { name: 'count'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'meanScore': { name: 'meanScore'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Float'; ofType: null; }; } }; 'mediaIds': { name: 'mediaIds'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; }; } }; 'minutesWatched': { name: 'minutesWatched'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'voiceActor': { name: 'voiceActor'; type: { kind: 'OBJECT'; name: 'Staff'; ofType: null; } }; }; };
'YearStats': { kind: 'OBJECT'; name: 'YearStats'; fields: { 'amount': { name: 'amount'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'meanScore': { name: 'meanScore'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'year': { name: 'year'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; }; };
};
};
/** An IntrospectionQuery representation of your schema.
*
* @remarks
* This is an introspection of your schema saved as a file by GraphQLSP.
* It will automatically be used by `gql.tada` to infer the types of your GraphQL documents.
* If you need to reuse this data or update your `scalars`, update `tadaOutputLocation` to
* instead save to a .ts instead of a .d.ts file.
*/
export type introspection = {
name: never;
query: "Query";
mutation: "Mutation";
subscription: never;
types: introspection_types;
};
declare module "gql.tada" {