refactor: decouple Anilist watch status updates from API endpoint to an asynchronous queue worker.
This commit is contained in:
@@ -30,19 +30,11 @@ const DeleteMediaListEntryMutation = graphql(`
|
|||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
/** Updates the watch status for a title on Anilist. If the token is null, the watch status will not be updated.
|
export async function updateWatchStatusOnAnilist(
|
||||||
*
|
|
||||||
* @returns true if the watch status was updated or if the token was null, false if it was not
|
|
||||||
*/
|
|
||||||
export async function maybeUpdateWatchStatusOnAnilist(
|
|
||||||
titleId: number,
|
titleId: number,
|
||||||
watchStatus: WatchStatus | null,
|
watchStatus: WatchStatus | null,
|
||||||
aniListToken: string | undefined,
|
aniListToken: string,
|
||||||
) {
|
) {
|
||||||
if (!aniListToken) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new GraphQLClient("https://graphql.anilist.co/");
|
const client = new GraphQLClient("https://graphql.anilist.co/");
|
||||||
const headers = new Headers({ Authorization: `Bearer ${aniListToken}` });
|
const headers = new Headers({ Authorization: `Bearer ${aniListToken}` });
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ vi.mock("~/mocks", () => ({
|
|||||||
describe("requests the /watch-status route", () => {
|
describe("requests the /watch-status route", () => {
|
||||||
const db = getTestDb(env);
|
const db = getTestDb(env);
|
||||||
let app: typeof import("../../../src/index").app;
|
let app: typeof import("../../../src/index").app;
|
||||||
let maybeUpdateWatchStatusOnAnilist: any;
|
|
||||||
let queueTask: any;
|
|
||||||
let maybeScheduleNextAiringEpisode: any;
|
let maybeScheduleNextAiringEpisode: any;
|
||||||
let removeTask: any;
|
let removeTask: any;
|
||||||
|
|
||||||
@@ -31,10 +29,6 @@ describe("requests the /watch-status route", () => {
|
|||||||
await resetTestDb(db);
|
await resetTestDb(db);
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
vi.doMock("./anilist", () => ({
|
|
||||||
maybeUpdateWatchStatusOnAnilist: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.doMock("~/libs/tasks/queueTask", () => ({
|
vi.doMock("~/libs/tasks/queueTask", () => ({
|
||||||
queueTask: vi.fn().mockResolvedValue(undefined),
|
queueTask: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
@@ -52,10 +46,6 @@ describe("requests the /watch-status route", () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
app = (await import("~/index")).app;
|
app = (await import("~/index")).app;
|
||||||
maybeUpdateWatchStatusOnAnilist = (
|
|
||||||
await import("~/controllers/watch-status/anilist")
|
|
||||||
).maybeUpdateWatchStatusOnAnilist;
|
|
||||||
queueTask = (await import("~/libs/tasks/queueTask")).queueTask;
|
|
||||||
removeTask = (await import("~/libs/tasks/removeTask")).removeTask;
|
removeTask = (await import("~/libs/tasks/removeTask")).removeTask;
|
||||||
maybeScheduleNextAiringEpisode = (
|
maybeScheduleNextAiringEpisode = (
|
||||||
await import("~/libs/maybeScheduleNextAiringEpisode")
|
await import("~/libs/maybeScheduleNextAiringEpisode")
|
||||||
@@ -119,34 +109,6 @@ describe("requests the /watch-status route", () => {
|
|||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saving title, Anilist request fails, should succeed", async () => {
|
|
||||||
vi.mocked(maybeUpdateWatchStatusOnAnilist).mockRejectedValue(
|
|
||||||
new Error("Anilist failed"),
|
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
env,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(res.json()).resolves.toEqual({ success: true });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
// Should queue task if direct update fails
|
|
||||||
expect(queueTask).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("watch status is null, should succeed", async () => {
|
it("watch status is null, should succeed", async () => {
|
||||||
const res = await app.request(
|
const res = await app.request(
|
||||||
"/watch-status",
|
"/watch-status",
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import {
|
|||||||
} from "~/types/schema";
|
} from "~/types/schema";
|
||||||
import { WatchStatus } from "~/types/title/watchStatus";
|
import { WatchStatus } from "~/types/title/watchStatus";
|
||||||
|
|
||||||
import { maybeUpdateWatchStatusOnAnilist } from "./anilist";
|
|
||||||
|
|
||||||
const app = new OpenAPIHono<Cloudflare.Env>();
|
const app = new OpenAPIHono<Cloudflare.Env>();
|
||||||
|
|
||||||
const UpdateWatchStatusRequest = z.object({
|
const UpdateWatchStatusRequest = z.object({
|
||||||
@@ -109,30 +107,16 @@ app.openapi(route, async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await queueTask(
|
||||||
await maybeUpdateWatchStatusOnAnilist(
|
"ANILIST_UPDATES",
|
||||||
Number(titleId),
|
{
|
||||||
|
deviceId,
|
||||||
watchStatus,
|
watchStatus,
|
||||||
aniListToken,
|
titleId,
|
||||||
);
|
updateType: AnilistUpdateType.UpdateWatchStatus,
|
||||||
} catch (error) {
|
},
|
||||||
console.error("Failed to update watch status on Anilist");
|
{ req: c.req, scheduleConfig: { delay: { minute: 1 } } },
|
||||||
console.error(error);
|
);
|
||||||
if (isRetrying) {
|
|
||||||
return c.json(ErrorResponse, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await queueTask(
|
|
||||||
"ANILIST_UPDATES",
|
|
||||||
{
|
|
||||||
deviceId,
|
|
||||||
watchStatus,
|
|
||||||
titleId,
|
|
||||||
updateType: AnilistUpdateType.UpdateWatchStatus,
|
|
||||||
},
|
|
||||||
{ req: c.req, scheduleConfig: { delay: { minute: 1 } } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(SuccessResponse, { status: 200 });
|
return c.json(SuccessResponse, { status: 200 });
|
||||||
});
|
});
|
||||||
|
|||||||
31
src/index.ts
31
src/index.ts
@@ -5,6 +5,7 @@ import { maybeUpdateLastConnectedAt } from "~/controllers/maybeUpdateLastConnect
|
|||||||
import type { QueueName } from "~/libs/tasks/queueName.ts";
|
import type { QueueName } from "~/libs/tasks/queueName.ts";
|
||||||
|
|
||||||
import { onNewEpisode } from "./controllers/internal/new-episode";
|
import { onNewEpisode } from "./controllers/internal/new-episode";
|
||||||
|
import { AnilistUpdateType } from "./libs/anilist/updateType";
|
||||||
import type { QueueBody } from "./libs/tasks/queueTask";
|
import type { QueueBody } from "./libs/tasks/queueTask";
|
||||||
|
|
||||||
export const app = new OpenAPIHono<{ Bindings: Env }>();
|
export const app = new OpenAPIHono<{ Bindings: Env }>();
|
||||||
@@ -74,7 +75,30 @@ export default {
|
|||||||
async queue(batch) {
|
async queue(batch) {
|
||||||
switch (batch.queue as QueueName) {
|
switch (batch.queue as QueueName) {
|
||||||
case "ANILIST_UPDATES":
|
case "ANILIST_UPDATES":
|
||||||
batch.retryAll();
|
for (const message of (
|
||||||
|
batch as MessageBatch<QueueBody["ANILIST_UPDATES"]>
|
||||||
|
).messages) {
|
||||||
|
switch (message.body.updateType) {
|
||||||
|
case AnilistUpdateType.UpdateWatchStatus:
|
||||||
|
if (!message.body[AnilistUpdateType.UpdateWatchStatus]) {
|
||||||
|
throw new Error(
|
||||||
|
`Discarding update, unknown body ${JSON.stringify(message.body)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { updateWatchStatusOnAnilist } =
|
||||||
|
await import("~/controllers/watch-status/anilist");
|
||||||
|
const payload = message.body[AnilistUpdateType.UpdateWatchStatus];
|
||||||
|
await updateWatchStatusOnAnilist(
|
||||||
|
payload.titleId,
|
||||||
|
payload.watchStatus,
|
||||||
|
payload.aniListToken,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.ack();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "NEW_EPISODE":
|
case "NEW_EPISODE":
|
||||||
for (const message of (batch as MessageBatch<QueueBody["NEW_EPISODE"]>)
|
for (const message of (batch as MessageBatch<QueueBody["NEW_EPISODE"]>)
|
||||||
@@ -89,9 +113,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async scheduled(event, env, ctx) {
|
async scheduled(event, env, ctx) {
|
||||||
const { processDelayedTasks } = await import(
|
const { processDelayedTasks } =
|
||||||
"~/libs/tasks/processDelayedTasks"
|
await import("~/libs/tasks/processDelayedTasks");
|
||||||
);
|
|
||||||
await processDelayedTasks(env, ctx);
|
await processDelayedTasks(env, ctx);
|
||||||
},
|
},
|
||||||
} satisfies ExportedHandler<Env>;
|
} satisfies ExportedHandler<Env>;
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import type { QueueName } from "./queueName";
|
|||||||
|
|
||||||
export type QueueBody = {
|
export type QueueBody = {
|
||||||
ANILIST_UPDATES: {
|
ANILIST_UPDATES: {
|
||||||
deviceId: string;
|
[AnilistUpdateType.UpdateWatchStatus]: {
|
||||||
watchStatus: WatchStatus | null;
|
titleId: number;
|
||||||
titleId: number;
|
watchStatus: WatchStatus | null;
|
||||||
|
aniListToken: string;
|
||||||
|
};
|
||||||
updateType: AnilistUpdateType;
|
updateType: AnilistUpdateType;
|
||||||
};
|
};
|
||||||
NEW_EPISODE: { aniListId: number; episodeNumber: number };
|
NEW_EPISODE: { aniListId: number; episodeNumber: number };
|
||||||
|
|||||||
Reference in New Issue
Block a user