import { swaggerUI } from "@hono/swagger-ui"; import { OpenAPIHono } from "@hono/zod-openapi"; import { Duration, type DurationLike } from "luxon"; import { onNewEpisode } from "~/controllers/internal/new-episode"; import { maybeUpdateLastConnectedAt } from "~/controllers/maybeUpdateLastConnectedAt"; import { AnilistUpdateType } from "~/libs/anilist/updateType"; import { calculateExponentialBackoff } from "~/libs/calculateExponentialBackoff"; import type { QueueName } from "~/libs/tasks/queueName.ts"; import type { QueueBody } from "~/libs/tasks/queueTask"; export const app = new OpenAPIHono<{ Bindings: Env }>(); app.use(maybeUpdateLastConnectedAt); app.route( "/", await import("~/controllers/health-check").then( (controller) => controller.default, ), ); app.route( "/title", await import("~/controllers/title").then((controller) => controller.default), ); app.route( "/episodes", await import("~/controllers/episodes").then( (controller) => controller.default, ), ); app.route( "/search", await import("~/controllers/search").then((controller) => controller.default), ); app.route( "/watch-status", await import("~/controllers/watch-status").then( (controller) => controller.default, ), ); app.route( "/token", await import("~/controllers/token").then((controller) => controller.default), ); app.route( "/auth", await import("~/controllers/auth").then((controller) => controller.default), ); app.route( "/popular", await import("~/controllers/popular").then( (controller) => controller.default, ), ); app.route( "/internal", await import("~/controllers/internal").then( (controller) => controller.default, ), ); // The OpenAPI documentation will be available at /doc app.doc("/openapi.json", { openapi: "3.0.0", info: { version: "1.0.0", title: "Aniplay API", }, }); app.get("/docs", swaggerUI({ url: "/openapi.json" })); export default { fetch: app.fetch, async queue(batch) { onMessageQueue(batch, async (message, queueName) => { switch (queueName) { case "ANILIST_UPDATES": const anilistUpdateBody = message.body as QueueBody["ANILIST_UPDATES"]; switch (anilistUpdateBody.updateType) { case AnilistUpdateType.UpdateWatchStatus: if (!anilistUpdateBody[AnilistUpdateType.UpdateWatchStatus]) { console.error( `Discarding update, unknown body ${JSON.stringify(message.body)}`, ); return; } const { updateWatchStatusOnAnilist } = await import("~/controllers/watch-status/anilist"); const payload = anilistUpdateBody[AnilistUpdateType.UpdateWatchStatus]; await updateWatchStatusOnAnilist( payload.titleId, payload.watchStatus, payload.aniListToken, ); break; default: throw new Error( `Unhandled update type: ${anilistUpdateBody.updateType}`, ); } break; case "NEW_EPISODE": const newEpisodeBody = message.body as QueueBody["NEW_EPISODE"]; await onNewEpisode( newEpisodeBody.aniListId, newEpisodeBody.episodeNumber, ); break; default: throw new Error(`Unhandled queue name: ${queueName}`); } }); }, async scheduled(event, env, ctx) { const { processDelayedTasks } = await import("~/libs/tasks/processDelayedTasks"); await processDelayedTasks(env); }, } satisfies ExportedHandler; const retryDelayConfig: Partial< Record > = { NEW_EPISODE: { min: Duration.fromObject({ hours: 1 }), max: Duration.fromObject({ hours: 12 }), }, }; function onMessageQueue( messageBatch: MessageBatch, callback: (message: Message, queueName: QN) => void, ) { for (const message of messageBatch.messages) { try { callback(message as Message, messageBatch.queue as QN); message.ack(); } catch (error) { console.error( `Failed to process message ${message.id} for queue ${messageBatch.queue} with body ${JSON.stringify(message.body)}`, ); console.error(error); message.retry({ delaySeconds: calculateExponentialBackoff({ attempt: message.attempts, baseMin: retryDelayConfig[messageBatch.queue as QN]?.min, absCap: retryDelayConfig[messageBatch.queue as QN]?.max, }), }); } } } export { AnilistDurableObject as AnilistDo } from "~/libs/anilist/anilist-do.ts";