feat: migrate to cloudflare d1 and queues

This commit is contained in:
2025-11-28 16:32:35 +08:00
parent 00e1f82d85
commit bd958fb1ab
19 changed files with 294 additions and 276 deletions

3
.gitignore vendored
View File

@@ -1,7 +1,10 @@
node_modules node_modules
dist dist
.mf
.wrangler .wrangler
.dev.vars .dev.vars
*.db *.db
*.db-* *.db-*
.env .env
src/libs/anilist/anilist-do.ts
.idea/ChatHistory_schema_v3.xml

View File

@@ -1,12 +1,16 @@
import { config } from "dotenv";
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
config({ path: ".dev.vars" });
export default defineConfig({ export default defineConfig({
schema: "./src/models/schema.ts", schema: "./src/models/schema.ts",
out: "./drizzle", out: "./drizzle",
driver: "turso", driver: "d1-http",
dialect: "sqlite", dialect: "sqlite",
dbCredentials: { dbCredentials: {
url: process.env.TURSO_URL, accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
authToken: process.env.TURSO_AUTH_TOKEN, databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
}, },
}); });

View File

@@ -23,7 +23,6 @@
"@hono/swagger-ui": "^0.5.1", "@hono/swagger-ui": "^0.5.1",
"@hono/zod-openapi": "^0.19.5", "@hono/zod-openapi": "^0.19.5",
"@hono/zod-validator": "^0.2.2", "@hono/zod-validator": "^0.2.2",
"@libsql/client": "0.15.4",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"gql.tada": "^1.8.10", "gql.tada": "^1.8.10",
"graphql": "^16.12.0", "graphql": "^16.12.0",

36
pnpm-lock.yaml generated
View File

@@ -22,9 +22,6 @@ importers:
"@hono/zod-validator": "@hono/zod-validator":
specifier: ^0.2.2 specifier: ^0.2.2
version: 0.2.2(hono@4.10.4)(zod@3.25.76) version: 0.2.2(hono@4.10.4)(zod@3.25.76)
"@libsql/client":
specifier: 0.15.4
version: 0.15.4
drizzle-orm: drizzle-orm:
specifier: ^0.44.7 specifier: ^0.44.7
version: 0.44.7(@cloudflare/workers-types@4.20251014.0)(@libsql/client@0.15.4)(bun-types@1.3.1(@types/react@19.2.2)) version: 0.44.7(@cloudflare/workers-types@4.20251014.0)(@libsql/client@0.15.4)(bun-types@1.3.1(@types/react@19.2.2))
@@ -4997,10 +4994,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
optional: true
"@libsql/core@0.15.15": "@libsql/core@0.15.15":
dependencies: dependencies:
js-base64: 3.7.8 js-base64: 3.7.8
optional: true
"@libsql/darwin-arm64@0.5.22": "@libsql/darwin-arm64@0.5.22":
optional: true optional: true
@@ -5017,8 +5016,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
optional: true
"@libsql/isomorphic-fetch@0.3.1": {} "@libsql/isomorphic-fetch@0.3.1":
optional: true
"@libsql/isomorphic-ws@0.1.5": "@libsql/isomorphic-ws@0.1.5":
dependencies: dependencies:
@@ -5027,6 +5028,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
optional: true
"@libsql/linux-arm-gnueabihf@0.5.22": "@libsql/linux-arm-gnueabihf@0.5.22":
optional: true optional: true
@@ -5058,7 +5060,8 @@ snapshots:
outvariant: 1.4.3 outvariant: 1.4.3
strict-event-emitter: 0.5.1 strict-event-emitter: 0.5.1
"@neon-rs/load@0.0.4": {} "@neon-rs/load@0.0.4":
optional: true
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
dependencies: dependencies:
@@ -5262,6 +5265,7 @@ snapshots:
"@types/ws@8.18.1": "@types/ws@8.18.1":
dependencies: dependencies:
"@types/node": 24.9.2 "@types/node": 24.9.2
optional: true
"@vitest/expect@3.2.4": "@vitest/expect@3.2.4":
dependencies: dependencies:
@@ -5520,7 +5524,8 @@ snapshots:
csstype@3.2.2: {} csstype@3.2.2: {}
data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@4.0.1:
optional: true
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
@@ -5538,7 +5543,8 @@ snapshots:
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
detect-libc@2.0.2: {} detect-libc@2.0.2:
optional: true
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
@@ -5762,6 +5768,7 @@ snapshots:
dependencies: dependencies:
node-domexception: 1.0.0 node-domexception: 1.0.0
web-streams-polyfill: 3.3.3 web-streams-polyfill: 3.3.3
optional: true
fill-range@7.1.1: fill-range@7.1.1:
dependencies: dependencies:
@@ -5791,6 +5798,7 @@ snapshots:
formdata-polyfill@4.0.10: formdata-polyfill@4.0.10:
dependencies: dependencies:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -5979,7 +5987,8 @@ snapshots:
jose@5.10.0: {} jose@5.10.0: {}
js-base64@3.7.8: {} js-base64@3.7.8:
optional: true
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -6016,6 +6025,7 @@ snapshots:
"@libsql/linux-x64-gnu": 0.5.22 "@libsql/linux-x64-gnu": 0.5.22
"@libsql/linux-x64-musl": 0.5.22 "@libsql/linux-x64-musl": 0.5.22
"@libsql/win32-x64-msvc": 0.5.22 "@libsql/win32-x64-msvc": 0.5.22
optional: true
lilconfig@3.1.3: {} lilconfig@3.1.3: {}
@@ -6169,6 +6179,7 @@ snapshots:
data-uri-to-buffer: 4.0.1 data-uri-to-buffer: 4.0.1
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
optional: true
npm-run-path@5.3.0: npm-run-path@5.3.0:
dependencies: dependencies:
@@ -6242,7 +6253,8 @@ snapshots:
prettier@3.6.2: {} prettier@3.6.2: {}
promise-limit@2.7.0: {} promise-limit@2.7.0:
optional: true
psl@1.15.0: psl@1.15.0:
dependencies: dependencies:
@@ -6595,7 +6607,8 @@ snapshots:
- tsx - tsx
- yaml - yaml
web-streams-polyfill@3.3.3: {} web-streams-polyfill@3.3.3:
optional: true
web-streams-polyfill@4.0.0-beta.3: {} web-streams-polyfill@4.0.0-beta.3: {}
@@ -6701,7 +6714,8 @@ snapshots:
ws@8.18.0: {} ws@8.18.0: {}
ws@8.18.3: {} ws@8.18.3:
optional: true
y18n@5.0.8: {} y18n@5.0.8: {}

View File

@@ -159,7 +159,7 @@ app.openapi(route, async (c) => {
mediaListEntry.status, mediaListEntry.status,
); );
if (wasAdded) { if (wasAdded) {
await maybeScheduleNextAiringEpisode(c.req, media.id); await maybeScheduleNextAiringEpisode(media.id);
} }
} }

View File

@@ -2,6 +2,7 @@ import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono"; import { Hono } from "hono";
import { z } from "zod"; import { z } from "zod";
import { getEpisodesFromAniwatch } from "~/controllers/episodes/getByAniListId/aniwatch";
import { fetchEpisodeUrl } from "~/controllers/episodes/getEpisodeUrl"; import { fetchEpisodeUrl } from "~/controllers/episodes/getEpisodeUrl";
import { getAdminSdkCredentials } from "~/libs/gcloud/getAdminSdkCredentials"; import { getAdminSdkCredentials } from "~/libs/gcloud/getAdminSdkCredentials";
import { sendFcmMessage } from "~/libs/gcloud/sendFcmMessage"; import { sendFcmMessage } from "~/libs/gcloud/sendFcmMessage";
@@ -16,39 +17,21 @@ import {
const app = new Hono(); const app = new Hono();
app.post( export async function onNewEpisode(aniListId: number, episodeNumber: number) {
"/",
zValidator(
"json",
z.object({
aniListId: AniListIdSchema,
episodeNumber: EpisodeNumberSchema,
}),
),
async (c) => {
const { aniListId, episodeNumber } = await c.req.json<{
aniListId: number;
episodeNumber: number;
}>();
console.log( console.log(
`Internal new episode route, aniListId: ${aniListId}, episodeNumber: ${episodeNumber}`, `On new episode, aniListId: ${aniListId}, episodeNumber: ${episodeNumber}`,
); );
if (!(await isWatchingTitle(aniListId))) { if (!(await isWatchingTitle(aniListId))) {
console.log(`Title ${aniListId} is no longer being watched`); console.log(`Title ${aniListId} is no longer being watched`);
return c.json( return { success: true, result: { isNoLongerWatching: true } };
{ success: true, result: { isNoLongerWatching: true } },
200,
);
} }
const episodes = await getEpisodesFromAniwatch(aniListId);
const fetchUrlResult = await fetchEpisodeUrl({ aniListId, episodeNumber }); const fetchUrlResult = await fetchEpisodeUrl({ aniListId, episodeNumber });
if (!fetchUrlResult) { if (!fetchUrlResult) {
console.error(`Failed to fetch episode URL for episode`); console.error(`Failed to fetch episode URL for episode`);
return c.json( return { success: false, message: "Failed to fetch episode URL" };
{ success: false, message: "Failed to fetch episode URL" },
500,
);
} }
const tokens = await getTokensSubscribedToTitle(aniListId); const tokens = await getTokensSubscribedToTitle(aniListId);
@@ -69,9 +52,32 @@ app.post(
}), }),
); );
await maybeScheduleNextAiringEpisode(c.req, aniListId); await maybeScheduleNextAiringEpisode(aniListId);
return c.json(SuccessResponse, 200); return SuccessResponse;
}
app.post(
"/",
zValidator(
"json",
z.object({
aniListId: AniListIdSchema,
episodeNumber: EpisodeNumberSchema,
}),
),
async (c) => {
const { aniListId, episodeNumber } = await c.req.json<{
aniListId: number;
episodeNumber: number;
}>();
const result = await onNewEpisode(aniListId, episodeNumber, c.req);
if (result.success) {
return c.json(result, 200);
} else {
return c.json(result, 500);
}
}, },
); );

View File

@@ -87,7 +87,7 @@ export async function getUpcomingTitlesFromAnilist(req: HonoRequest) {
await Promise.all( await Promise.all(
Array.from(plannedToWatchTitles).map((titleId) => Array.from(plannedToWatchTitles).map((titleId) =>
maybeScheduleNextAiringEpisode(req, titleId), maybeScheduleNextAiringEpisode(titleId),
), ),
); );

View File

@@ -8,6 +8,6 @@ export const maybeUpdateLastConnectedAt = createMiddleware(async (c, next) => {
return next(); return next();
} }
await updateDeviceLastConnectedAt(c.env, deviceId); await updateDeviceLastConnectedAt(deviceId);
return next(); return next();
}); });

View File

@@ -1,6 +1,7 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import type { HonoRequest } from "hono"; import type { HonoRequest } from "hono";
import { AnilistUpdateType } from "~/libs/anilist/updateType.ts";
import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode"; import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode";
import { buildNewEpisodeTaskId } from "~/libs/tasks/id"; import { buildNewEpisodeTaskId } from "~/libs/tasks/id";
import { queueTask } from "~/libs/tasks/queueTask"; import { queueTask } from "~/libs/tasks/queueTask";
@@ -76,9 +77,9 @@ export async function updateWatchStatus(
watchStatus, watchStatus,
); );
if (wasAdded) { if (wasAdded) {
await maybeScheduleNextAiringEpisode(req, titleId); await maybeScheduleNextAiringEpisode(titleId);
} else if (wasDeleted) { } else if (wasDeleted) {
await removeTask("new-episode", buildNewEpisodeTaskId(titleId)); await removeTask("NEW_EPISODE", buildNewEpisodeTaskId(titleId));
} }
} }
@@ -115,13 +116,12 @@ app.openapi(route, async (c) => {
} }
await queueTask( await queueTask(
"anilist-updates", "ANILIST_UPDATES",
{ {
deviceId, deviceId,
watchStatus, watchStatus,
titleId, titleId,
isRetrying: true, updateType: AnilistUpdateType.UpdateWatchStatus,
nameSuffix: "watch-status",
}, },
{ req: c.req, scheduleConfig: { delay: { minute: 1 } } }, { req: c.req, scheduleConfig: { delay: { minute: 1 } } },
); );

View File

@@ -2,6 +2,10 @@ import { swaggerUI } from "@hono/swagger-ui";
import { OpenAPIHono } from "@hono/zod-openapi"; import { OpenAPIHono } from "@hono/zod-openapi";
import { maybeUpdateLastConnectedAt } from "~/controllers/maybeUpdateLastConnectedAt"; import { maybeUpdateLastConnectedAt } from "~/controllers/maybeUpdateLastConnectedAt";
import type { QueueName } from "~/libs/tasks/queueName.ts";
import { onNewEpisode } from "./controllers/internal/new-episode";
import type { QueueBody } from "./libs/tasks/queueTask";
const app = new OpenAPIHono(); const app = new OpenAPIHono();
@@ -65,6 +69,25 @@ app.doc("/openapi.json", {
app.get("/docs", swaggerUI({ url: "/openapi.json" })); app.get("/docs", swaggerUI({ url: "/openapi.json" }));
export default app; export default {
...app,
async queue(batch) {
switch (batch.queue as QueueName) {
case "ANILIST_UPDATES":
batch.retryAll();
break;
case "NEW_EPISODE":
for (const message of (batch as MessageBatch<QueueBody["NEW_EPISODE"]>)
.messages) {
await onNewEpisode(
message.body.aniListId,
message.body.episodeNumber,
);
message.ack();
}
break;
}
},
} satisfies ExportedHandler<Env>;
export { AnilistDurableObject as AnilistDo } from "~/libs/anilist/anilist-do.ts"; export { AnilistDurableObject as AnilistDo } from "~/libs/anilist/anilist-do.ts";

View File

@@ -1,10 +1,22 @@
import { DurableObject, env } from "cloudflare:workers"; import { DurableObject, env } from "cloudflare:workers";
import { GraphQLClient } from "graphql-request"; import { graphql, type ResultOf } from "gql.tada";
import { print } from "graphql";
import { z } from "zod"; import { z } from "zod";
import { GetTitleQuery, _fetchTitleFromAnilist } from "~/libs/anilist/getTitle";
import { sleep } from "~/libs/sleep.ts"; import { sleep } from "~/libs/sleep.ts";
import type { Title } from "~/types/title"; import type { Title } from "~/types/title";
import { MediaFragment } from "~/types/title/mediaFragment";
const GetTitleQuery = graphql(
`
query GetTitle($id: Int!) {
Media(id: $id) {
...Media
}
}
`,
[MediaFragment],
);
const nextAiringEpisodeSchema = z.nullable( const nextAiringEpisodeSchema = z.nullable(
z.object({ z.object({
@@ -29,12 +41,13 @@ export class AnilistDurableObject extends DurableObject {
switch (operationName) { switch (operationName) {
case "GetTitle": { case "GetTitle": {
const { variables } = body; const { variables } = body;
const cache = await this.state.storage.get(variables.id); const storageKey = variables.id;
if (cache) { const cache = await this.state.storage.get(storageKey);
return new Response(JSON.stringify(cache), { // if (cache) {
headers: { "Content-Type": "application/json" }, // return new Response(JSON.stringify(cache), {
}); // headers: { "Content-Type": "application/json" },
} // });
// }
const anilistResponse = await this.fetchTitleFromAnilist( const anilistResponse = await this.fetchTitleFromAnilist(
variables.id, variables.id,
@@ -45,7 +58,7 @@ export class AnilistDurableObject extends DurableObject {
); );
const airingAt = (nextAiringEpisode?.airingAt ?? 0) * 1000; const airingAt = (nextAiringEpisode?.airingAt ?? 0) * 1000;
await this.state.storage.put(variables.id, anilistResponse); await this.state.storage.put(storageKey, anilistResponse);
if (airingAt) { if (airingAt) {
await this.state.storage.setAlarm(airingAt); await this.state.storage.setAlarm(airingAt);
await this.state.storage.put(`alarm:${variables.id}`, airingAt); await this.state.storage.put(`alarm:${variables.id}`, airingAt);
@@ -63,7 +76,6 @@ export class AnilistDurableObject extends DurableObject {
async alarm() { async alarm() {
const now = Date.now(); const now = Date.now();
const alarms = await this.state.storage.list({ prefix: "alarm:" }); const alarms = await this.state.storage.list({ prefix: "alarm:" });
console.log("alarm", now, alarms);
for (const [id, ttl] of Object.entries(alarms)) { for (const [id, ttl] of Object.entries(alarms)) {
if (now >= ttl) { if (now >= ttl) {
await this.state.storage.delete(id); await this.state.storage.delete(id);
@@ -75,30 +87,70 @@ export class AnilistDurableObject extends DurableObject {
id: number, id: number,
token?: string | undefined, token?: string | undefined,
): Promise<Title | undefined> { ): Promise<Title | undefined> {
const client = new GraphQLClient("https://graphql.anilist.co/"); const headers: any = {
const headers = new Headers(); "Content-Type": "application/json",
};
if (token) { if (token) {
headers.append("Authorization", `Bearer ${token}`); headers["Authorization"] = `Bearer ${token}`;
} }
return client const response = await fetch("http://localhost:3000/proxy", {
.request(GetTitleQuery, { id }, headers) method: "POST",
.then((data) => data?.Media ?? undefined) headers: {
.catch((error) => { "Content-Type": "application/json",
if (error.message.includes("Not Found")) { },
body: JSON.stringify({
url: "https://graphql.anilist.co/",
method: "POST",
headers, // Pass the original headers here
data: {
operationName: "GetTitle",
query: print(GetTitleQuery),
variables: { id },
},
}),
});
// 1. Handle Rate Limiting (429)
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
console.log("429, retrying in", retryAfter);
await sleep(Number(retryAfter || 1) * 1000); // specific fallback or ensure logic
return this.fetchTitleFromAnilist(id, token);
}
// 2. Handle HTTP Errors (like 404 or 500)
if (!response.ok) {
// If it is specifically a 404 Not Found HTTP status
if (response.status === 404) {
return undefined; return undefined;
} }
if (error.response?.status === 429) {
console.log( console.error(JSON.stringify(await response.json(), null, 2));
"429, retrying in", // Throw for other HTTP errors to be caught by caller
error.response.headers.get("Retry-After"), throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
);
return sleep(
Number(error.response.headers.get("Retry-After")!) * 1000,
).then(() => this.fetchTitleFromAnilist(id, token));
} }
throw error; // 3. Parse JSON
}); // We cast this to ResultOf<typeof GetTitleQuery> to maintain Tada type safety
const result = (await response.json().then((json) => json.data)) as {
data?: ResultOf<typeof GetTitleQuery>;
errors?: any[];
};
// 4. Handle GraphQL Specific Errors (Anilist might return 200 OK but include errors)
if (result.errors && result.errors.length > 0) {
const errorMessage = JSON.stringify(result.errors);
if (errorMessage.includes("Not Found")) {
return undefined;
}
throw new Error(`GraphQL Error: ${errorMessage}`);
}
return result.data?.Media ?? undefined;
} }
} }

View File

@@ -1,19 +1,6 @@
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import { graphql } from "gql.tada";
import type { Title } from "~/types/title"; import type { Title } from "~/types/title";
import { MediaFragment } from "~/types/title/mediaFragment";
export const GetTitleQuery = graphql(
`
query GetTitle($id: Int!) {
Media(id: $id) {
...Media
}
}
`,
[MediaFragment],
);
export async function fetchTitleFromAnilist( export async function fetchTitleFromAnilist(
id: number, id: number,

View File

@@ -0,0 +1,3 @@
export enum AnilistUpdateType {
UpdateWatchStatus,
}

View File

@@ -1,4 +1,3 @@
import type { HonoRequest } from "hono";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { import {
@@ -7,18 +6,9 @@ import {
} from "~/models/unreleasedTitles"; } from "~/models/unreleasedTitles";
import { getNextEpisodeTimeUntilAiring } from "./anilist/getNextEpisodeAiringAt"; import { getNextEpisodeTimeUntilAiring } from "./anilist/getNextEpisodeAiringAt";
import { getCurrentDomain } from "./getCurrentDomain";
import { queueTask } from "./tasks/queueTask"; import { queueTask } from "./tasks/queueTask";
export async function maybeScheduleNextAiringEpisode( export async function maybeScheduleNextAiringEpisode(aniListId: number) {
req: HonoRequest,
aniListId: number,
) {
const domain = getCurrentDomain(req);
if (!domain) {
return;
}
const { nextAiring, status } = await getNextEpisodeTimeUntilAiring(aniListId); const { nextAiring, status } = await getNextEpisodeTimeUntilAiring(aniListId);
if ( if (
!nextAiring || !nextAiring ||
@@ -33,9 +23,9 @@ export async function maybeScheduleNextAiringEpisode(
const { airingAt, episode: nextEpisode } = nextAiring; const { airingAt, episode: nextEpisode } = nextAiring;
await queueTask( await queueTask(
"new-episode", "NEW_EPISODE",
{ aniListId, episodeNumber: nextEpisode }, { aniListId, episodeNumber: nextEpisode },
{ req, scheduleConfig: { epochTime: airingAt } }, { scheduleConfig: { epochTime: airingAt } },
); );
await removeUnreleasedTitle(aniListId); await removeUnreleasedTitle(aniListId);
} }

View File

@@ -1 +1,5 @@
export type QueueName = "anilist-updates" | "new-episode"; type QueueKeys<T> = {
[K in keyof T]: T[K] extends Queue ? K : never;
}[keyof T];
export type QueueName = QueueKeys<Cloudflare.Env>;

View File

@@ -1,26 +1,20 @@
import { env as cloudflareEnv } from "cloudflare:workers"; import { env as cloudflareEnv } from "cloudflare:workers";
import type { HonoRequest } from "hono"; import type { HonoRequest } from "hono";
import isEqual from "lodash.isequal"; import { DateTime, Duration, type DurationLike } from "luxon";
import { DateTime, type DurationLike } from "luxon";
import { AnilistUpdateType } from "~/libs/anilist/updateType.ts";
import type { WatchStatus } from "~/types/title/watchStatus"; import type { WatchStatus } from "~/types/title/watchStatus";
import { FailedToQueueTaskError } from "../errors/FailedToQueueTask";
import { getAdminSdkCredentials } from "../gcloud/getAdminSdkCredentials";
import { getGoogleAuthToken } from "../gcloud/getGoogleAuthToken";
import { getCurrentDomain } from "../getCurrentDomain";
import { buildAnilistRetryTaskId, buildNewEpisodeTaskId } from "./id";
import type { QueueName } from "./queueName"; import type { QueueName } from "./queueName";
type QueueBody = { export type QueueBody = {
"anilist-updates": { ANILIST_UPDATES: {
deviceId: string; deviceId: string;
watchStatus: WatchStatus | null; watchStatus: WatchStatus | null;
titleId: number; titleId: number;
isRetrying: true; updateType: AnilistUpdateType;
nameSuffix: string;
}; };
"new-episode": { aniListId: number; episodeNumber: number }; NEW_EPISODE: { aniListId: number; episodeNumber: number };
}; };
type ScheduleConfig = type ScheduleConfig =
@@ -39,164 +33,76 @@ export async function queueTask(
body: QueueBody[QueueName], body: QueueBody[QueueName],
{ scheduleConfig, req, env = cloudflareEnv }: QueueTaskOptionalArgs = {}, { scheduleConfig, req, env = cloudflareEnv }: QueueTaskOptionalArgs = {},
) { ) {
const domain = req const { scheduleTime, headers } = buildTask(
? getCurrentDomain(req)
: "https://aniplay-v2.rururu.workers.dev";
if (!domain) {
console.log("Skipping queue task due to local domain", queueName, body);
return;
}
const adminSdkCredentials = getAdminSdkCredentials(env);
const { projectId } = adminSdkCredentials;
const task = buildTask(
projectId,
queueName, queueName,
scheduleConfig, scheduleConfig,
domain,
body, body,
req?.header(), req?.header(),
); );
const { res } = await queueCloudTask(task); const contentType =
headers["Content-Type"] === "application/json" ? "json" : "text";
if (!res.ok) { if (!env) {
if (res.status === 409) { const Cloudflare = await import("cloudflare").then(
if ( ({ Cloudflare }) => Cloudflare,
await checkIfTaskExists(
env,
queueName,
task.name.split("/").at(-1)!,
body,
)
) {
return;
} else {
const hashedTaskName = await import("node:crypto").then(
({ createHash }) =>
createHash("sha256")
.update(task.name.split("/").at(-1)!)
.digest("hex"),
); );
console.log("task name", hashedTaskName); const client = new Cloudflare({ apiToken: env.CLOUDFLARE_TOKEN });
const { res } = await queueCloudTask({ let queueId: string | null = null;
...task, const queues = await client.queues.list({
name: account_id: env.CLOUDFLARE_ACCOUNT_ID,
task.name.split("/").slice(0, -1).join("/") + "/" + hashedTaskName,
}); });
if (!res.ok) { for await (const queue of queues) {
if (await checkIfTaskExists(env, queueName, hashedTaskName, body)) { if (queueId == queue.queue_name) {
return; queueId = queue.queue_id!;
}
}
if (!queueId) {
throw new Error(`Queue ${queueName} not found`);
} }
throw new FailedToQueueTaskError(res.status, await res.text()); await client.queues.messages.push(queueId, {
} body: { body, headers },
} content_type: contentType,
} delay_seconds: scheduleTime,
account_id: env.CLOUDFLARE_ACCOUNT_ID,
throw new FailedToQueueTaskError(res.status, await res.text()); });
} } else {
await env[queueName].send(
async function queueCloudTask(task: object) { { body, headers },
const res = await fetch(
`https://content-cloudtasks.googleapis.com/v2/projects/${projectId}/locations/northamerica-northeast1/queues/${queueName}/tasks?alt=json`,
{ {
headers: { contentType,
Authorization: `Bearer ${await getGoogleAuthToken(adminSdkCredentials)}`, delaySeconds: scheduleTime,
"Content-Type": "application/json",
},
body: JSON.stringify({ task }),
method: "POST",
}, },
); );
return { res };
} }
} }
async function checkIfTaskExists(
env: Cloudflare.Env,
queueName: QueueName,
taskId: string,
expectedBody: QueueBody[QueueName],
) {
const adminSdkCredentials = getAdminSdkCredentials(env);
const body = await fetch(
`https://content-cloudtasks.googleapis.com/v2/projects/${adminSdkCredentials.projectId}/locations/northamerica-northeast1/queues/${queueName}/tasks/${taskId}?responseView=FULL`,
{
headers: {
Authorization: `Bearer ${await getGoogleAuthToken(adminSdkCredentials)}`,
},
},
)
.then((res) => res.json())
.then(({ httpRequest }) => httpRequest?.body);
return (
body &&
isEqual(
JSON.parse(Buffer.from(body as string, "base64").toString()),
expectedBody,
)
);
}
function buildTask( function buildTask(
projectId: string,
queueName: QueueName, queueName: QueueName,
scheduleConfig: ScheduleConfig | undefined, scheduleConfig: ScheduleConfig | undefined,
domain: string,
body: QueueBody[QueueName], body: QueueBody[QueueName],
headers: Record<string, string> | undefined, headers: Record<string, string> | undefined,
) { ) {
let scheduleTime: string | undefined; let scheduleTime: number = 0;
if (scheduleConfig) { if (scheduleConfig) {
const { delay, epochTime } = scheduleConfig; const { delay, epochTime } = scheduleConfig;
if (epochTime) { if (epochTime) {
scheduleTime = DateTime.fromSeconds(epochTime).toUTC().toISO(); scheduleTime = DateTime.fromSeconds(epochTime)
.diffNow("second")
.as("second");
} else if (delay) { } else if (delay) {
scheduleTime = DateTime.now().plus(delay).toUTC().toISO(); scheduleTime = Duration.fromDurationLike(delay).as("second");
} }
} }
let taskId: string;
switch (queueName) { switch (queueName) {
case "new-episode": case "ANILIST_UPDATES":
const { aniListId } = body as QueueBody["new-episode"]; case "NEW_EPISODE":
taskId = buildNewEpisodeTaskId(aniListId);
return { return {
name: `projects/${projectId}/locations/northamerica-northeast1/queues/${queueName}/tasks/${taskId}`, body,
scheduleTime, scheduleTime,
httpRequest: {
url: `${domain}/internal/new-episode`,
httpMethod: "POST",
body: Buffer.from(JSON.stringify(body)).toString("base64"),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Anilist-Token": headers?.["X-Anilist-Token"], "X-Anilist-Token": headers?.["X-Anilist-Token"],
}, },
},
};
case "anilist-updates":
const { deviceId, titleId, nameSuffix } =
body as QueueBody[typeof queueName];
taskId = buildAnilistRetryTaskId(deviceId, titleId, nameSuffix);
return {
name: `projects/${projectId}/locations/northamerica-northeast1/queues/${queueName}/tasks/${taskId}`,
scheduleTime,
httpRequest: {
url: `${domain}/watch-status`,
httpMethod: "POST",
body: Buffer.from(
JSON.stringify({ ...body, nameSuffix: undefined }),
).toString("base64"),
headers: {
"Content-Type": "application/json",
"X-Anilist-Token": headers?.["X-Anilist-Token"],
},
},
}; };
default: default:
throw new Error(`Unknown queue name: ${queueName}`); throw new Error(`Unknown queue name: ${queueName}`);

View File

@@ -1,20 +1,15 @@
import { createClient } from "@libsql/client"; // import { createClient } from "@libsql/client";
import { env as cloudflareEnv } from "cloudflare:workers"; import { env as cloudflareEnv } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/libsql"; import { drizzle } from "drizzle-orm/d1";
type Db = ReturnType<typeof drizzle>; type Db = ReturnType<typeof drizzle>;
let db: Db | null = null; // let db: Db | null = null;
export function getDb(env: Cloudflare.Env = cloudflareEnv): Db { export function getDb(env: Cloudflare.Env = cloudflareEnv): Db {
if (db) { // if (db) {
return db; // return db;
} // }
const client = createClient({ const db = drizzle(env.DB, { logger: true });
url: env.TURSO_URL,
authToken: env.TURSO_AUTH_TOKEN,
});
db = drizzle(client);
return db; return db;
} }

View File

@@ -1,5 +1,5 @@
/* eslint-disable */ /* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: ebfd0a114715273ef48ef9f20e119f14) // Generated by Wrangler by running `wrangler types` (hash: 55b6bc5eef0b3709210a3c57164a6bda)
// Runtime types generated with workerd@1.20251011.0 2024-09-23 nodejs_compat // Runtime types generated with workerd@1.20251011.0 2024-09-23 nodejs_compat
declare namespace Cloudflare { declare namespace Cloudflare {
interface GlobalProps { interface GlobalProps {
@@ -11,7 +11,14 @@ declare namespace Cloudflare {
TURSO_AUTH_TOKEN: string; TURSO_AUTH_TOKEN: string;
ENABLE_ANIFY: string; ENABLE_ANIFY: string;
ADMIN_SDK_JSON: string; ADMIN_SDK_JSON: string;
CLOUDFLARE_TOKEN: string;
CLOUDFLARE_D1_TOKEN: string;
CLOUDFLARE_ACCOUNT_ID: string;
CLOUDFLARE_DATABASE_ID: string;
ANILIST_DO: DurableObjectNamespace<import("./src/index").AnilistDo>; ANILIST_DO: DurableObjectNamespace<import("./src/index").AnilistDo>;
DB: D1Database;
ANILIST_UPDATES: Queue;
NEW_EPISODE: Queue;
} }
} }
interface Env extends Cloudflare.Env {} interface Env extends Cloudflare.Env {}

View File

@@ -17,4 +17,29 @@ class_name = "AnilistDo"
[[migrations]] [[migrations]]
tag = "v1" tag = "v1"
new_classes = ["AniwatchApiContainer"]
[[migrations]]
tag = "<v2>"
deleted_classes = ["AniwatchApiContainer"]
new_classes = ["AnilistDo"] new_classes = ["AnilistDo"]
[[queues.producers]]
queue = "anilist-updates"
binding = "ANILIST_UPDATES"
[[queues.producers]]
queue = "new-episode"
binding = "NEW_EPISODE"
[[queues.consumers]]
queue = "anilist-updates"
[[queues.consumers]]
queue = "new-episode"
[[d1_databases]]
binding = "DB"
database_name = "aniplay"
database_id = "5083d01d-7444-4336-a629-7c3e2002b13d"
migrations_dir = "drizzle"