feat: support fetching "currently watching" titles when logging in

This commit is contained in:
2024-09-20 00:06:22 -04:00
parent 079a9402e4
commit 432da61aec
6 changed files with 281 additions and 3 deletions

View File

@@ -0,0 +1,27 @@
import { graphql } from "gql.tada";
import { GraphQLClient } from "graphql-request";
const GetUsernameQuery = graphql(`
query GetUsername {
Viewer {
name
}
}
`);
export function getUsername(aniListToken: string) {
const client = new GraphQLClient("https://graphql.anilist.co/");
return client
.request(GetUsernameQuery, undefined, {
Authorization: `Bearer ${aniListToken}`,
})
.then((data) => data?.Viewer?.name)
.catch((err) => {
if (err.response?.status === 401 || err.response?.status === 429) {
return null;
}
throw err;
});
}

View File

@@ -0,0 +1,79 @@
import { graphql } from "gql.tada";
import { GraphQLClient } from "graphql-request";
const GetWatchingTitlesQuery = graphql(`
query GetWatchingTitles($userName: String!, $page: Int!) {
Page(page: $page, perPage: 50) {
mediaList(
userName: $userName
type: ANIME
sort: UPDATED_TIME_DESC
status_in: [CURRENT, REPEATING, PLANNING]
) {
media {
id
idMal
title {
english
userPreferred
}
description
episodes
genres
status
bannerImage
averageScore
coverImage {
extraLarge
large
medium
}
countryOfOrigin
mediaListEntry {
id
progress
status
}
nextAiringEpisode {
timeUntilAiring
airingAt
episode
}
}
}
pageInfo {
currentPage
hasNextPage
perPage
}
}
}
`);
export function getWatchingTitles(
username: string,
page: number,
executionCtx: ExecutionContext,
) {
const client = new GraphQLClient("https://graphql.anilist.co/");
return client
.request(GetWatchingTitlesQuery, { userName: username, page })
.then((data) => data?.Page!)
.catch((err) => {
const response = err.response;
if (response.status === 429) {
console.log("429, retrying in", response.headers.get("Retry-After"));
executionCtx.waitUntil(
sleep(Number(response.headers.get("Retry-After")!) * 1000),
);
return getWatchingTitles(username, page, executionCtx);
}
throw err;
});
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,161 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { env } from "hono/adapter";
import { streamSSE } from "hono/streaming";
import { fetchEpisodes } from "~/controllers/episodes/getByAniListId";
import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode";
import { readEnvVariable } from "~/libs/readEnvVariable";
import { setWatchStatus } from "~/models/watchStatus";
import type { Env } from "~/types/env";
import { EpisodesResponseSchema } from "~/types/episode";
import { ErrorResponse, ErrorResponseSchema } from "~/types/schema";
import { Title } from "~/types/title";
import { getUsername } from "./getUsername";
import { getWatchingTitles } from "./getWatchingTitles";
const route = createRoute({
tags: ["aniplay", "auth"],
summary:
"Authenticate with AniList and return all upcoming and 'currently watching' titles",
operationId: "authenticateAniList",
method: "get",
path: "/",
request: {
query: z.object({
token: z.string(),
deviceId: z.string(),
// "x-anilist-token": z.string(),
// "x-aniplay-device-id": z.string(),
}),
},
responses: {
200: {
content: {
"text/event-stream": {
schema: z.object({ title: Title, episodes: EpisodesResponseSchema }),
},
},
description: "Streams a list of titles",
},
401: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Failed to authenticate with AniList",
},
500: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Error fetching episodes",
},
},
});
const app = new OpenAPIHono<Env>();
app.openapi(route, async (c) => {
// const deviceId = await c.req.header("X-Aniplay-Device-Id");
// const aniListToken = await c.req.header("X-AniList-Token");
const { deviceId, token: aniListToken } = await c.req.query();
if (!aniListToken) {
return c.json(ErrorResponse, { status: 401 });
}
try {
const username = await getUsername(aniListToken);
if (!username) {
return c.json(ErrorResponse, { status: 401 });
}
return streamSSE(
c,
async (stream) => {
let currentPage = 1;
let hasNextPage = true;
do {
const { mediaList, pageInfo } = await getWatchingTitles(
username,
currentPage++,
c.executionCtx,
);
if (!mediaList) {
break;
}
for (const mediaObj of mediaList) {
const media = mediaObj?.media!;
if (!media) {
continue;
}
const mediaListEntry = media.mediaListEntry;
if (mediaListEntry) {
const { wasAdded } = await setWatchStatus(
env(c, "workerd"),
deviceId!,
media.id,
mediaListEntry.status,
);
if (wasAdded) {
await maybeScheduleNextAiringEpisode(
env<Env, typeof c>(c, "workerd"),
c.req,
media.id,
);
}
}
const nextEpisode = media.nextAiringEpisode?.episode;
if (
nextEpisode === 0 ||
nextEpisode === 1 ||
media.status === "NOT_YET_RELEASED"
) {
await stream.writeSSE({
event: "title",
data: JSON.stringify({ title: media, episodes: [] }),
id: media.id.toString(),
});
continue;
}
await fetchEpisodes(
media.id,
readEnvVariable<boolean>(c.env, "ENABLE_ANIFY"),
).then(({ result: episodes }) => {
stream.writeSSE({
event: "title",
data: JSON.stringify({ title: media, episodes }),
id: media.id.toString(),
});
});
}
hasNextPage = pageInfo?.hasNextPage ?? false;
console.log(hasNextPage);
} while (hasNextPage);
await stream.close();
},
async (err, stream) => {
stream.writeln("An error occurred!");
console.error(err);
},
);
} catch (error) {
console.error(
new Error("Failed to authenticate with AniList", { cause: error }),
);
return c.json(ErrorResponse, { status: 500 });
}
});
export default app;

View File

@@ -0,0 +1,10 @@
import { OpenAPIHono } from "@hono/zod-openapi";
const app = new OpenAPIHono();
app.route(
"/anilist",
await import("./anilist").then((controller) => controller.default),
);
export default app;