feat: add user profile fetch in middleware

This commit is contained in:
2025-12-18 08:46:50 -05:00
parent b64bd4fc26
commit 4c96f58cb0
8 changed files with 114 additions and 44 deletions

View File

@@ -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) {

View File

@@ -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 }>();

View File

@@ -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(
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 },
token,
);
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,
);
const airingAt = (nextAiringEpisode?.airingAt ?? 0) * 1000;
if (airingAt) {
return airingAt - Date.now();
}
return undefined;
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) {

View File

@@ -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;
}

View File

@@ -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!) {

View File

@@ -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();
});

View File

@@ -21,11 +21,6 @@ export const MediaFragment = graphql(`
medium
}
countryOfOrigin
mediaListEntry {
id
progress
status
}
nextAiringEpisode {
timeUntilAiring
airingAt

View File

@@ -3,12 +3,18 @@ import { z } from "zod";
export type User = z.infer<typeof User>;
export const User = z
.object({
id: z.number().openapi({ type: "integer", format: "int64" }),
name: 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() /* .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" }),
@@ -17,6 +23,4 @@ export const User = z
medium: z.string(),
large: z.string(),
}),
})
.optional()
.nullable();
});