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 { fetchTitleFromAnilist } from "~/libs/anilist/getTitle";
import { fetchFromMultipleSources } from "~/libs/fetchFromMultipleSources"; import { fetchFromMultipleSources } from "~/libs/fetchFromMultipleSources";
import { userProfileMiddleware } from "~/middleware/userProfile";
import { import {
AniListIdQuerySchema, AniListIdQuerySchema,
ErrorResponse, ErrorResponse,
@@ -9,6 +10,7 @@ import {
SuccessResponseSchema, SuccessResponseSchema,
} from "~/types/schema"; } from "~/types/schema";
import { Title } from "~/types/title"; import { Title } from "~/types/title";
import type { User } from "~/types/user";
const app = new OpenAPIHono(); const app = new OpenAPIHono();
@@ -40,6 +42,7 @@ const route = createRoute({
description: "Title could not be found", description: "Title could not be found",
}, },
}, },
middleware: [userProfileMiddleware],
}); });
app.openapi(route, async (c) => { app.openapi(route, async (c) => {
@@ -55,7 +58,12 @@ app.openapi(route, async (c) => {
} }
const { result: title, errorOccurred } = await fetchFromMultipleSources([ const { result: title, errorOccurred } = await fetchFromMultipleSources([
() => fetchTitleFromAnilist(aniListId, aniListToken ?? undefined), () =>
fetchTitleFromAnilist(
aniListId,
(c.get("user") as User)?.id,
aniListToken ?? undefined,
),
]); ]);
if (errorOccurred) { if (errorOccurred) {

View File

@@ -3,7 +3,6 @@ import { OpenAPIHono } from "@hono/zod-openapi";
import { Duration, type DurationLike } from "luxon"; import { Duration, type DurationLike } from "luxon";
import { onNewEpisode } from "~/controllers/internal/new-episode"; import { onNewEpisode } from "~/controllers/internal/new-episode";
import { maybeUpdateLastConnectedAt } from "~/controllers/maybeUpdateLastConnectedAt";
import { AnilistUpdateType } from "~/libs/anilist/updateType"; import { AnilistUpdateType } from "~/libs/anilist/updateType";
import { calculateExponentialBackoff } from "~/libs/calculateExponentialBackoff"; import { calculateExponentialBackoff } from "~/libs/calculateExponentialBackoff";
import type { QueueName } from "~/libs/tasks/queueName.ts"; import type { QueueName } from "~/libs/tasks/queueName.ts";
@@ -11,6 +10,7 @@ import {
MAX_QUEUE_DELAY_SECONDS, MAX_QUEUE_DELAY_SECONDS,
type QueueBody, type QueueBody,
} from "~/libs/tasks/queueTask"; } from "~/libs/tasks/queueTask";
import { maybeUpdateLastConnectedAt } from "~/middleware/maybeUpdateLastConnectedAt";
export const app = new OpenAPIHono<{ Bindings: Env }>(); export const app = new OpenAPIHono<{ Bindings: Env }>();

View File

@@ -8,6 +8,7 @@ import {
GetNextEpisodeAiringAtQuery, GetNextEpisodeAiringAtQuery,
GetPopularTitlesQuery, GetPopularTitlesQuery,
GetTitleQuery, GetTitleQuery,
GetTitleUserDataQuery,
GetTrendingTitlesQuery, GetTrendingTitlesQuery,
GetUpcomingTitlesQuery, GetUpcomingTitlesQuery,
GetUserProfileQuery, GetUserProfileQuery,
@@ -18,6 +19,7 @@ import {
SearchQuery, SearchQuery,
} from "~/libs/anilist/queries"; } from "~/libs/anilist/queries";
import { sleep } from "~/libs/sleep.ts"; import { sleep } from "~/libs/sleep.ts";
import type { Title } from "~/types/title";
const nextAiringEpisodeSchema = z.nullable( const nextAiringEpisodeSchema = z.nullable(
z.object({ z.object({
@@ -38,30 +40,54 @@ export class AnilistDurableObject extends DurableObject {
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
} }
async getTitle(id: number, token?: string) { async getTitle(
return this.handleCachedRequest( id: number,
`title:${id}`, userId?: string,
async () => { token?: string,
const anilistResponse = await this.fetchFromAnilist( ): Promise<Title | null> {
GetTitleQuery, const promises: Promise<any>[] = [
{ id }, this.handleCachedRequest(
token, `title:${id}`,
); async () => {
return anilistResponse?.Media ?? null; const anilistResponse = await this.fetchFromAnilist(GetTitleQuery, {
}, id,
(media) => { });
if (!media) return undefined; return anilistResponse?.Media ?? null;
// Cast to any to access fragment fields without unmasking },
const nextAiringEpisode = nextAiringEpisodeSchema.parse( (media) => {
(media as any)?.nextAiringEpisode, if (!media) return undefined;
);
const airingAt = (nextAiringEpisode?.airingAt ?? 0) * 1000; // Cast to any to access fragment fields without unmasking
if (airingAt) { const nextAiringEpisode = nextAiringEpisodeSchema.parse(
return airingAt - Date.now(); (media as any)?.nextAiringEpisode,
} );
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) { async getNextEpisodeAiringAt(id: number) {

View File

@@ -5,6 +5,7 @@ import type { Title } from "~/types/title";
export async function fetchTitleFromAnilist( export async function fetchTitleFromAnilist(
id: number, id: number,
userId?: number | undefined,
token?: string | undefined, token?: string | undefined,
): Promise<Title | undefined> { ): Promise<Title | undefined> {
if (useMockData()) { if (useMockData()) {
@@ -17,8 +18,7 @@ export async function fetchTitleFromAnilist(
); );
const stub = env.ANILIST_DO.get(durableObjectId); const stub = env.ANILIST_DO.get(durableObjectId);
const data = await stub.getTitle(id, token); const data = await stub.getTitle(id, userId, token);
if (!data) { if (!data) {
return undefined; return undefined;
} }

View File

@@ -14,6 +14,18 @@ export const GetTitleQuery = graphql(
[MediaFragment], [MediaFragment],
); );
export const GetTitleUserDataQuery = graphql(`
query GetTitleUserData($id: Int!) {
Media(id: $id) {
mediaListEntry {
id
progress
status
}
}
}
`);
export const SearchQuery = graphql( export const SearchQuery = graphql(
` `
query Search($query: String!, $page: Int!, $limit: Int!) { 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 medium
} }
countryOfOrigin countryOfOrigin
mediaListEntry {
id
progress
status
}
nextAiringEpisode { nextAiringEpisode {
timeUntilAiring timeUntilAiring
airingAt airingAt

View File

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