feat: create route to handle updating watch status in AniList

This commit is contained in:
2024-06-12 09:33:55 -04:00
parent 09ffff7c56
commit 0a859e0f16
8 changed files with 284 additions and 10 deletions

View File

@@ -0,0 +1,30 @@
import { graphql } from "gql.tada";
import { GraphQLClient } from "graphql-request";
import type { WatchStatus } from "~/types/title";
const UpdateWatchStatusQuery = graphql(`
mutation UpdateWatchStatus($titleId: Int!, $watchStatus: MediaListStatus!) {
SaveMediaListEntry(mediaId: $titleId, status: $watchStatus) {
id
}
}
`);
/** Updates the watch status for a title on Anilist. If the token is null, the watch status will not be updated. */
export async function maybeUpdateWatchStatusOnAnilist(
titleId: number,
watchStatus: WatchStatus,
aniListToken: string | undefined,
) {
if (!aniListToken) {
return;
}
const client = new GraphQLClient("https://graphql.anilist.co/");
const headers = new Headers({ Authorization: `Bearer ${aniListToken}` });
return client
.request(UpdateWatchStatusQuery, { titleId, watchStatus }, headers)
.then((data) => !!data?.SaveMediaListEntry?.id);
}

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it } from "bun:test";
import app from "~/index";
import { server } from "~/mocks";
import { getDb, resetDb } from "~/models/db";
import { tokenTable } from "~/models/schema";
server.listen();
console.error = () => {};
describe("requests the /watch-status route", () => {
const db = getDb({
TURSO_URL: "http://127.0.0.1:3000",
TURSO_AUTH_TOKEN: "asd",
});
beforeEach(async () => {
await resetDb();
});
it("saving title, deviceId in db, should succeed", async () => {
await db.insert(tokenTable).values({ deviceId: "123", token: "asd" });
const res = await app.request(
"/watch-status",
{
method: "POST",
headers: new Headers({
"x-anilist-token": "asd",
"Content-Type": "application/json",
}),
body: JSON.stringify({
deviceId: "123",
watchStatus: "CURRENT",
titleId: 10,
}),
},
{
TURSO_URL: process.env.TURSO_URL,
TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN,
},
);
expect(res.json()).resolves.toEqual({ success: true });
expect(res.status).toBe(200);
});
it("saving title, deviceId not in db, should fail", async () => {
const res = await app.request(
"/watch-status",
{
method: "POST",
headers: new Headers({
"x-anilist-token": "asd",
"Content-Type": "application/json",
}),
body: JSON.stringify({
deviceId: "123",
watchStatus: "CURRENT",
titleId: 10,
}),
},
{
TURSO_URL: process.env.TURSO_URL,
TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN,
},
);
expect(res.json()).resolves.toEqual({ success: false });
expect(res.status).toBe(500);
});
it("saving title, Anilist request fails, should succeed", async () => {
await db.insert(tokenTable).values({ deviceId: "123", token: "asd" });
const res = await app.request(
"/watch-status",
{
method: "POST",
headers: new Headers({
"x-anilist-token": "asd",
"Content-Type": "application/json",
}),
body: JSON.stringify({
deviceId: "123",
watchStatus: "CURRENT",
titleId: -1,
}),
},
{
TURSO_URL: process.env.TURSO_URL,
TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN,
},
);
expect(res.json()).resolves.toEqual({ success: true });
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,96 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { env } from "hono/adapter";
import { setWatchStatus } from "~/models/watchStatus";
import type { Env } from "~/types/env";
import {
AniListIdSchema,
ErrorResponse,
SuccessResponse,
} from "~/types/schema";
import { WatchStatus } from "~/types/title/watchStatus";
import { maybeUpdateWatchStatusOnAnilist } from "./anilist";
const app = new OpenAPIHono<Env>();
const UpdateWatchStatusRequest = z.object({
deviceId: z.string(),
watchStatus: WatchStatus,
titleId: AniListIdSchema,
isRetrying: z.boolean().optional().default(false),
});
const route = createRoute({
tags: ["aniplay", "title"],
operationId: "updateWatchStatus",
summary: "Update watch status for a title",
description:
"Updates the watch status for a title. If the user sets the watch status to 'watching', they'll start getting notified about new episodes.",
method: "post",
path: "/",
request: {
body: {
content: {
"application/json": {
schema: UpdateWatchStatusRequest,
},
},
},
headers: z.object({ "x-anilist-token": z.string().nullish() }),
},
responses: {
200: {
content: {
"application/json": {
schema: z.boolean(),
},
},
description: "Watch status was successfully updated",
},
500: {
content: {
"application/json": {
schema: z.boolean(),
},
},
description: "Failed to update watch status",
},
},
});
app.openapi(route, async (c) => {
const { deviceId, watchStatus, titleId, isRetrying } =
await c.req.json<typeof UpdateWatchStatusRequest._type>();
const aniListToken = c.req.header("X-AniList-Token");
if (!isRetrying) {
try {
const { wasAdded, wasDeleted } = await setWatchStatus(
env<Env, typeof c>(c, "workerd"),
deviceId,
Number(titleId),
watchStatus,
);
} catch (error) {
console.error(new Error("Error setting watch status", { cause: error }));
return c.json(ErrorResponse, { status: 500 });
}
}
try {
await maybeUpdateWatchStatusOnAnilist(
Number(titleId),
watchStatus,
aniListToken,
);
} catch (error) {
console.error(
new Error("Failed to update watch status on Anilist", { cause: error }),
);
}
return c.json(SuccessResponse, { status: 200 });
});
export default app;

View File

@@ -23,6 +23,12 @@ app.route(
"/search", "/search",
await import("~/controllers/search").then((controller) => controller.default), await import("~/controllers/search").then((controller) => controller.default),
); );
app.route(
"/watch-status",
await import("~/controllers/watch-status").then(
(controller) => controller.default,
),
);
// The OpenAPI documentation will be available at /doc // The OpenAPI documentation will be available at /doc
app.doc("/openapi.json", { app.doc("/openapi.json", {

View File

@@ -0,0 +1,37 @@
import { HttpResponse, graphql } from "msw";
export function updateAnilistWatchStatus() {
return graphql.mutation(
"UpdateWatchStatus",
({ variables: { titleId, watchStatus }, request: { headers } }) => {
console.log(
`Intercepting UpdateWatchStatus mutation with ID ${titleId}, watch status ${watchStatus} and Authorization header ${headers.get("authorization")}`,
);
if (titleId === -1) {
return HttpResponse.json({
errors: [
{
message: "validation",
status: 400,
locations: [
{
line: 2,
column: 2,
},
],
validation: {
mediaId: ["The selected media id is invalid."],
},
},
],
data: {
SaveMediaListEntry: null,
},
});
}
return HttpResponse.json({ data: { id: titleId } });
},
);
}

View File

@@ -7,10 +7,12 @@ import { getAnifySources } from "./anify/sources";
import { getAnifyTitle } from "./anify/title"; import { getAnifyTitle } from "./anify/title";
import { getAnilistSearchResults } from "./anilist/search"; import { getAnilistSearchResults } from "./anilist/search";
import { getAnilistTitle } from "./anilist/title"; import { getAnilistTitle } from "./anilist/title";
import { updateAnilistWatchStatus } from "./anilist/updateWatchStatus";
export const handlers = [ export const handlers = [
getAnilistSearchResults(), getAnilistSearchResults(),
getAnilistTitle(), getAnilistTitle(),
updateAnilistWatchStatus(),
getAmvstrmEpisodes(), getAmvstrmEpisodes(),
getAmvstrmSources(), getAmvstrmSources(),
getAmvstrmSearchResults(), getAmvstrmSearchResults(),

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { NullableNumberSchema } from "../schema"; import { NullableNumberSchema } from "../schema";
import { countryCodeSchema } from "./countryCodes"; import { countryCodeSchema } from "./countryCodes";
import { WatchStatus } from "./watchStatus";
export type Title = z.infer<typeof Title>; export type Title = z.infer<typeof Title>;
export const Title = z.object({ export const Title = z.object({
@@ -14,16 +15,7 @@ export const Title = z.object({
), ),
mediaListEntry: z.nullable( mediaListEntry: z.nullable(
z.object({ z.object({
status: z.nullable( status: z.nullable(WatchStatus),
z.enum([
"CURRENT",
"PLANNING",
"COMPLETED",
"DROPPED",
"PAUSED",
"REPEATING",
]),
),
progress: NullableNumberSchema, progress: NullableNumberSchema,
id: z.number().int(), id: z.number().int(),
}), }),

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export type WatchStatus = z.infer<typeof WatchStatus>;
export const WatchStatusValues = [
"CURRENT",
"PLANNING",
"COMPLETED",
"DROPPED",
"PAUSED",
"REPEATING",
] as const;
export const WatchStatus = z.enum(WatchStatusValues);