feat: create route to handle updating watch status in AniList
This commit is contained in:
30
src/controllers/watch-status/anilist.ts
Normal file
30
src/controllers/watch-status/anilist.ts
Normal 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);
|
||||||
|
}
|
||||||
99
src/controllers/watch-status/index.spec.ts
Normal file
99
src/controllers/watch-status/index.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
src/controllers/watch-status/index.ts
Normal file
96
src/controllers/watch-status/index.ts
Normal 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;
|
||||||
@@ -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", {
|
||||||
|
|||||||
37
src/mocks/anilist/updateWatchStatus.ts
Normal file
37
src/mocks/anilist/updateWatchStatus.ts
Normal 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 } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
12
src/types/title/watchStatus.ts
Normal file
12
src/types/title/watchStatus.ts
Normal 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);
|
||||||
Reference in New Issue
Block a user