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[] = [
+ 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 {
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;
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;
+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(),
+ }),
+});