From 0a859e0f1667de2de5ca0c59a1f1d720637eaaf2 Mon Sep 17 00:00:00 2001 From: Rushil Perera Date: Wed, 12 Jun 2024 09:33:55 -0400 Subject: [PATCH] feat: create route to handle updating watch status in AniList --- src/controllers/watch-status/anilist.ts | 30 +++++++ src/controllers/watch-status/index.spec.ts | 99 ++++++++++++++++++++++ src/controllers/watch-status/index.ts | 96 +++++++++++++++++++++ src/index.ts | 6 ++ src/mocks/anilist/updateWatchStatus.ts | 37 ++++++++ src/mocks/handlers.ts | 2 + src/types/title/index.ts | 12 +-- src/types/title/watchStatus.ts | 12 +++ 8 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 src/controllers/watch-status/anilist.ts create mode 100644 src/controllers/watch-status/index.spec.ts create mode 100644 src/controllers/watch-status/index.ts create mode 100644 src/mocks/anilist/updateWatchStatus.ts create mode 100644 src/types/title/watchStatus.ts diff --git a/src/controllers/watch-status/anilist.ts b/src/controllers/watch-status/anilist.ts new file mode 100644 index 0000000..9e568dd --- /dev/null +++ b/src/controllers/watch-status/anilist.ts @@ -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); +} diff --git a/src/controllers/watch-status/index.spec.ts b/src/controllers/watch-status/index.spec.ts new file mode 100644 index 0000000..19cc453 --- /dev/null +++ b/src/controllers/watch-status/index.spec.ts @@ -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); + }); +}); diff --git a/src/controllers/watch-status/index.ts b/src/controllers/watch-status/index.ts new file mode 100644 index 0000000..9b8a4d1 --- /dev/null +++ b/src/controllers/watch-status/index.ts @@ -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(); + +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(); + const aniListToken = c.req.header("X-AniList-Token"); + + if (!isRetrying) { + try { + const { wasAdded, wasDeleted } = await setWatchStatus( + env(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; diff --git a/src/index.ts b/src/index.ts index 4e561d1..fdfc163 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,12 @@ app.route( "/search", 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 app.doc("/openapi.json", { diff --git a/src/mocks/anilist/updateWatchStatus.ts b/src/mocks/anilist/updateWatchStatus.ts new file mode 100644 index 0000000..9affb44 --- /dev/null +++ b/src/mocks/anilist/updateWatchStatus.ts @@ -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 } }); + }, + ); +} diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index adfc3d9..928ff95 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -7,10 +7,12 @@ import { getAnifySources } from "./anify/sources"; import { getAnifyTitle } from "./anify/title"; import { getAnilistSearchResults } from "./anilist/search"; import { getAnilistTitle } from "./anilist/title"; +import { updateAnilistWatchStatus } from "./anilist/updateWatchStatus"; export const handlers = [ getAnilistSearchResults(), getAnilistTitle(), + updateAnilistWatchStatus(), getAmvstrmEpisodes(), getAmvstrmSources(), getAmvstrmSearchResults(), diff --git a/src/types/title/index.ts b/src/types/title/index.ts index 7f2d4e4..98cbc0e 100644 --- a/src/types/title/index.ts +++ b/src/types/title/index.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { NullableNumberSchema } from "../schema"; import { countryCodeSchema } from "./countryCodes"; +import { WatchStatus } from "./watchStatus"; export type Title = z.infer; export const Title = z.object({ @@ -14,16 +15,7 @@ export const Title = z.object({ ), mediaListEntry: z.nullable( z.object({ - status: z.nullable( - z.enum([ - "CURRENT", - "PLANNING", - "COMPLETED", - "DROPPED", - "PAUSED", - "REPEATING", - ]), - ), + status: z.nullable(WatchStatus), progress: NullableNumberSchema, id: z.number().int(), }), diff --git a/src/types/title/watchStatus.ts b/src/types/title/watchStatus.ts new file mode 100644 index 0000000..39359c4 --- /dev/null +++ b/src/types/title/watchStatus.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export type WatchStatus = z.infer; +export const WatchStatusValues = [ + "CURRENT", + "PLANNING", + "COMPLETED", + "DROPPED", + "PAUSED", + "REPEATING", +] as const; +export const WatchStatus = z.enum(WatchStatusValues);