From 4c96f58cb0a64ce60f31e6894f287a7dc605b018 Mon Sep 17 00:00:00 2001 From: Rushil Perera Date: Thu, 18 Dec 2025 08:46:50 -0500 Subject: [PATCH] feat: add user profile fetch in middleware --- src/controllers/title/index.ts | 10 ++++- src/index.ts | 2 +- src/libs/anilist/anilist-do.ts | 72 ++++++++++++++++++++++---------- src/libs/anilist/getTitle.ts | 4 +- src/libs/anilist/queries.ts | 12 ++++++ src/middleware/userProfile.ts | 25 +++++++++++ src/types/title/mediaFragment.ts | 5 --- src/types/user.ts | 28 +++++++------ 8 files changed, 114 insertions(+), 44 deletions(-) create mode 100644 src/middleware/userProfile.ts diff --git a/src/controllers/title/index.ts b/src/controllers/title/index.ts index 848695e..609d853 100644 --- a/src/controllers/title/index.ts +++ b/src/controllers/title/index.ts @@ -2,6 +2,7 @@ import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; import { fetchTitleFromAnilist } from "~/libs/anilist/getTitle"; import { fetchFromMultipleSources } from "~/libs/fetchFromMultipleSources"; +import { userProfileMiddleware } from "~/middleware/userProfile"; import { AniListIdQuerySchema, ErrorResponse, @@ -9,6 +10,7 @@ import { SuccessResponseSchema, } from "~/types/schema"; import { Title } from "~/types/title"; +import type { User } from "~/types/user"; const app = new OpenAPIHono(); @@ -40,6 +42,7 @@ const route = createRoute({ description: "Title could not be found", }, }, + middleware: [userProfileMiddleware], }); app.openapi(route, async (c) => { @@ -55,7 +58,12 @@ app.openapi(route, async (c) => { } const { result: title, errorOccurred } = await fetchFromMultipleSources([ - () => fetchTitleFromAnilist(aniListId, aniListToken ?? undefined), + () => + fetchTitleFromAnilist( + aniListId, + (c.get("user") as User)?.id, + aniListToken ?? undefined, + ), ]); if (errorOccurred) { diff --git a/src/index.ts b/src/index.ts index 60dd6c3..ff869f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,6 @@ 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"; @@ -11,6 +10,7 @@ import { MAX_QUEUE_DELAY_SECONDS, type QueueBody, } from "~/libs/tasks/queueTask"; +import { maybeUpdateLastConnectedAt } from "~/middleware/maybeUpdateLastConnectedAt"; export const app = new OpenAPIHono<{ Bindings: Env }>(); diff --git a/src/libs/anilist/anilist-do.ts b/src/libs/anilist/anilist-do.ts index 781f9af..4e9e405 100644 --- a/src/libs/anilist/anilist-do.ts +++ b/src/libs/anilist/anilist-do.ts @@ -8,6 +8,7 @@ import { GetNextEpisodeAiringAtQuery, GetPopularTitlesQuery, GetTitleQuery, + GetTitleUserDataQuery, GetTrendingTitlesQuery, GetUpcomingTitlesQuery, GetUserProfileQuery, @@ -18,6 +19,7 @@ import { SearchQuery, } from "~/libs/anilist/queries"; import { sleep } from "~/libs/sleep.ts"; +import type { Title } from "~/types/title"; const nextAiringEpisodeSchema = z.nullable( z.object({ @@ -38,30 +40,54 @@ export class AnilistDurableObject extends DurableObject { 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 getTitle( + id: number, + userId?: string, + token?: string, + ): Promise { + 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) { diff --git a/src/libs/anilist/getTitle.ts b/src/libs/anilist/getTitle.ts index 1fca88e..eb87c9b 100644 --- a/src/libs/anilist/getTitle.ts +++ b/src/libs/anilist/getTitle.ts @@ -5,6 +5,7 @@ import type { Title } from "~/types/title"; export async function fetchTitleFromAnilist( id: number, + userId?: number | undefined, token?: string | undefined, ): Promise<Title | undefined> { if (useMockData()) { @@ -17,8 +18,7 @@ export async function fetchTitleFromAnilist( ); const stub = env.ANILIST_DO.get(durableObjectId); - const data = await stub.getTitle(id, token); - + const data = await stub.getTitle(id, userId, token); if (!data) { return undefined; } diff --git a/src/libs/anilist/queries.ts b/src/libs/anilist/queries.ts index 6787c13..506a1a9 100644 --- a/src/libs/anilist/queries.ts +++ b/src/libs/anilist/queries.ts @@ -14,6 +14,18 @@ export const GetTitleQuery = graphql( [MediaFragment], ); +export const GetTitleUserDataQuery = graphql(` + query GetTitleUserData($id: Int!) { + Media(id: $id) { + mediaListEntry { + id + progress + status + } + } + } +`); + export const SearchQuery = graphql( ` query Search($query: String!, $page: Int!, $limit: Int!) { diff --git a/src/middleware/userProfile.ts b/src/middleware/userProfile.ts new file mode 100644 index 0000000..7874ce6 --- /dev/null +++ b/src/middleware/userProfile.ts @@ -0,0 +1,25 @@ +import { createMiddleware } from "hono/factory"; + +import type { User } from "~/types/user"; + +export const userProfileMiddleware = createMiddleware< + Cloudflare.Env & { + Variables: { + user: User; + }; + Bindings: Env; + } +>(async (c, next) => { + const aniListToken = await c.req.header("X-AniList-Token"); + if (!aniListToken) { + return next(); + } + + const user = await c.env.ANILIST_DO.getByName("GLOBAL").getUser(aniListToken); + if (!user) { + return c.json({ error: "User not found" }, 401); + } + + c.set("user", user); + return next(); +}); diff --git a/src/types/title/mediaFragment.ts b/src/types/title/mediaFragment.ts index edd62e0..1da09d4 100644 --- a/src/types/title/mediaFragment.ts +++ b/src/types/title/mediaFragment.ts @@ -21,11 +21,6 @@ export const MediaFragment = graphql(` medium } countryOfOrigin - mediaListEntry { - id - progress - status - } nextAiringEpisode { timeUntilAiring airingAt diff --git a/src/types/user.ts b/src/types/user.ts index 7834a7a..9168aca 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -3,20 +3,24 @@ import { z } from "zod"; export type User = z.infer<typeof User>; export const User = z .object({ - statistics: z.object({ - minutesWatched: z.number().openapi({ type: "integer", format: "int64" }), - episodesWatched: z.number().openapi({ type: "integer", format: "int64" }), - count: z - .number() - .int() /* .openapi({ type: "integer", format: "int64" }) */, - meanScore: z.number().openapi({ type: "number", format: "float" }), - }), id: z.number().openapi({ type: "integer", format: "int64" }), name: z.string(), - avatar: z.object({ - medium: z.string(), - large: z.string(), - }), }) .optional() .nullable(); + +export type UserProfile = z.infer<typeof UserProfile>; +export const UserProfile = z.object({ + statistics: z.object({ + minutesWatched: z.number().openapi({ type: "integer", format: "int64" }), + episodesWatched: z.number().openapi({ type: "integer", format: "int64" }), + count: z.number().int(), + meanScore: z.number().openapi({ type: "number", format: "float" }), + }), + id: z.number().openapi({ type: "integer", format: "int64" }), + name: z.string(), + avatar: z.object({ + medium: z.string(), + large: z.string(), + }), +});