From 432da61aec7a19715f8fa7fd26569a85dd7061e3 Mon Sep 17 00:00:00 2001 From: Rushil Perera Date: Fri, 20 Sep 2024 00:06:22 -0400 Subject: [PATCH] feat: support fetching "currently watching" titles when logging in --- src/controllers/auth/anilist/getUsername.ts | 27 +++ .../auth/anilist/getWatchingTitles.ts | 79 +++++++++ src/controllers/auth/anilist/index.ts | 161 ++++++++++++++++++ src/controllers/auth/index.ts | 10 ++ src/controllers/internal/new-episode/index.ts | 3 - src/index.ts | 4 + 6 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 src/controllers/auth/anilist/getUsername.ts create mode 100644 src/controllers/auth/anilist/getWatchingTitles.ts create mode 100644 src/controllers/auth/anilist/index.ts create mode 100644 src/controllers/auth/index.ts diff --git a/src/controllers/auth/anilist/getUsername.ts b/src/controllers/auth/anilist/getUsername.ts new file mode 100644 index 0000000..ff2c0c5 --- /dev/null +++ b/src/controllers/auth/anilist/getUsername.ts @@ -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; + }); +} diff --git a/src/controllers/auth/anilist/getWatchingTitles.ts b/src/controllers/auth/anilist/getWatchingTitles.ts new file mode 100644 index 0000000..434c6bf --- /dev/null +++ b/src/controllers/auth/anilist/getWatchingTitles.ts @@ -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)); +} diff --git a/src/controllers/auth/anilist/index.ts b/src/controllers/auth/anilist/index.ts new file mode 100644 index 0000000..0d4d35c --- /dev/null +++ b/src/controllers/auth/anilist/index.ts @@ -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(); + +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(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(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; diff --git a/src/controllers/auth/index.ts b/src/controllers/auth/index.ts new file mode 100644 index 0000000..939f32d --- /dev/null +++ b/src/controllers/auth/index.ts @@ -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; diff --git a/src/controllers/internal/new-episode/index.ts b/src/controllers/internal/new-episode/index.ts index 4a5f605..96f942e 100644 --- a/src/controllers/internal/new-episode/index.ts +++ b/src/controllers/internal/new-episode/index.ts @@ -10,15 +10,12 @@ import { fetchEpisodeUrl } from "~/controllers/episodes/getEpisodeUrl"; import { Case, changeStringCase } from "~/libs/changeStringCase"; import type { AdminSdkCredentials } from "~/libs/fcm/getGoogleAuthToken"; import { sendFcmMessage } from "~/libs/fcm/sendFcmMessage"; -import { getCurrentDomain } from "~/libs/getCurrentDomain"; import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode"; import { verifyQstashHeader } from "~/libs/qstash/verifyQstashHeader"; import { readEnvVariable } from "~/libs/readEnvVariable"; import { getTokensSubscribedToTitle } from "~/models/token"; import { isWatchingTitle } from "~/models/watchStatus"; import type { Env } from "~/types/env"; -import type { EpisodesResponseSchema } from "~/types/episode"; -import type { FetchUrlResponse } from "~/types/episode/fetch-url-response"; import { AniListIdSchema, EpisodeNumberSchema, diff --git a/src/index.ts b/src/index.ts index b89c6b5..143a7ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,10 @@ app.route( "/token", await import("~/controllers/token").then((controller) => controller.default), ); +app.route( + "/auth", + await import("~/controllers/auth").then((controller) => controller.default), +); app.route( "/internal", await import("~/controllers/internal").then(