feat: support fetching "currently watching" titles when logging in
This commit is contained in:
27
src/controllers/auth/anilist/getUsername.ts
Normal file
27
src/controllers/auth/anilist/getUsername.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
79
src/controllers/auth/anilist/getWatchingTitles.ts
Normal file
79
src/controllers/auth/anilist/getWatchingTitles.ts
Normal 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));
|
||||||
|
}
|
||||||
161
src/controllers/auth/anilist/index.ts
Normal file
161
src/controllers/auth/anilist/index.ts
Normal 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;
|
||||||
10
src/controllers/auth/index.ts
Normal file
10
src/controllers/auth/index.ts
Normal 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;
|
||||||
@@ -10,15 +10,12 @@ import { fetchEpisodeUrl } from "~/controllers/episodes/getEpisodeUrl";
|
|||||||
import { Case, changeStringCase } from "~/libs/changeStringCase";
|
import { Case, changeStringCase } from "~/libs/changeStringCase";
|
||||||
import type { AdminSdkCredentials } from "~/libs/fcm/getGoogleAuthToken";
|
import type { AdminSdkCredentials } from "~/libs/fcm/getGoogleAuthToken";
|
||||||
import { sendFcmMessage } from "~/libs/fcm/sendFcmMessage";
|
import { sendFcmMessage } from "~/libs/fcm/sendFcmMessage";
|
||||||
import { getCurrentDomain } from "~/libs/getCurrentDomain";
|
|
||||||
import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode";
|
import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode";
|
||||||
import { verifyQstashHeader } from "~/libs/qstash/verifyQstashHeader";
|
import { verifyQstashHeader } from "~/libs/qstash/verifyQstashHeader";
|
||||||
import { readEnvVariable } from "~/libs/readEnvVariable";
|
import { readEnvVariable } from "~/libs/readEnvVariable";
|
||||||
import { getTokensSubscribedToTitle } from "~/models/token";
|
import { getTokensSubscribedToTitle } from "~/models/token";
|
||||||
import { isWatchingTitle } from "~/models/watchStatus";
|
import { isWatchingTitle } from "~/models/watchStatus";
|
||||||
import type { Env } from "~/types/env";
|
import type { Env } from "~/types/env";
|
||||||
import type { EpisodesResponseSchema } from "~/types/episode";
|
|
||||||
import type { FetchUrlResponse } from "~/types/episode/fetch-url-response";
|
|
||||||
import {
|
import {
|
||||||
AniListIdSchema,
|
AniListIdSchema,
|
||||||
EpisodeNumberSchema,
|
EpisodeNumberSchema,
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ app.route(
|
|||||||
"/token",
|
"/token",
|
||||||
await import("~/controllers/token").then((controller) => controller.default),
|
await import("~/controllers/token").then((controller) => controller.default),
|
||||||
);
|
);
|
||||||
|
app.route(
|
||||||
|
"/auth",
|
||||||
|
await import("~/controllers/auth").then((controller) => controller.default),
|
||||||
|
);
|
||||||
app.route(
|
app.route(
|
||||||
"/internal",
|
"/internal",
|
||||||
await import("~/controllers/internal").then(
|
await import("~/controllers/internal").then(
|
||||||
|
|||||||
Reference in New Issue
Block a user