feat: support sending "new title" alerts to devices

This commit is contained in:
2024-09-02 13:02:05 -04:00
parent ae99918524
commit 4fe22bccf3
16 changed files with 381 additions and 5 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -0,0 +1,4 @@
CREATE TABLE `key_value` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL
);

View File

@@ -0,0 +1,122 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bca1f597-6db1-4bf8-ab6b-a95c10d3f6a7",
"prevId": "223cc621-0232-4499-973a-9013d134b1f9",
"tables": {
"device_tokens": {
"name": "device_tokens",
"columns": {
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_connected_at": {
"name": "last_connected_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {
"device_tokens_token_unique": {
"name": "device_tokens_token_unique",
"columns": ["token"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"key_value": {
"name": "key_value",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"watch_status": {
"name": "watch_status",
"columns": {
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title_id": {
"name": "title_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"watch_status_device_id_device_tokens_device_id_fk": {
"name": "watch_status_device_id_device_tokens_device_id_fk",
"tableFrom": "watch_status",
"tableTo": "device_tokens",
"columnsFrom": ["device_id"],
"columnsTo": ["device_id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"watch_status_device_id_title_id_pk": {
"columns": ["device_id", "title_id"],
"name": "watch_status_device_id_title_id_pk"
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -36,6 +36,13 @@
"when": 1718402777422,
"tag": "0004_jittery_black_knight",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1725293569918,
"tag": "0005_shiny_scarecrow",
"breakpoints": true
}
]
}

View File

@@ -21,6 +21,7 @@
"@hono/swagger-ui": "^0.2.2",
"@hono/zod-openapi": "^0.12.0",
"@libsql/client": "^0.6.2",
"@upstash/qstash": "^2.7.0",
"drizzle-orm": "^0.31.2",
"gql.tada": "^1.7.5",
"graphql-request": "^7.0.1",

View File

@@ -2,8 +2,7 @@ import { graphql } from "gql.tada";
import { GraphQLClient } from "graphql-request";
import type { Title } from "~/types/title";
import { MediaFragment } from "./mediaFragment";
import { MediaFragment } from "~/types/title/mediaFragment";
const GetTitleQuery = graphql(
`

View File

@@ -0,0 +1,10 @@
import { Hono } from "hono";
const app = new Hono();
app.route(
"/",
await import("./titles").then((controller) => controller.default),
);
export default app;

View File

@@ -0,0 +1,89 @@
import { graphql } from "gql.tada";
import { GraphQLClient } from "graphql-request";
import { DateTime } from "luxon";
import { getValue, setValue } from "~/models/kv";
import type { Env } from "~/types/env";
import type { Title } from "~/types/title";
import { MediaFragment } from "~/types/title/mediaFragment";
const GetUpcomingTitlesQuery = graphql(
`
query GetUpcomingTitles(
$page: Int!
$airingAtLowerBound: Int!
$airingAtUpperBound: Int!
) {
Page(page: $page) {
airingSchedules(
notYetAired: true
sort: TIME
airingAt_lesser: $airingAtUpperBound
airingAt_greater: $airingAtLowerBound
) {
id
airingAt
timeUntilAiring
episode
media {
...Media
}
}
pageInfo {
hasNextPage
}
}
}
`,
[MediaFragment],
);
type AiringSchedule = {
media: Title;
episode: number;
timeUntilAiring: number;
airingAt: number;
id: number;
};
export async function getUpcomingTitlesFromAnilist(env: Env) {
const client = new GraphQLClient("https://graphql.anilist.co/");
const lastCheckedScheduleAt = await getValue(
env,
"schedule_last_checked_at",
).then((value) => (value ? Number(value) : DateTime.now().toUnixInteger()));
console.log(lastCheckedScheduleAt);
const twoDaysFromNow = DateTime.now().plus({ days: 2 }).toUnixInteger();
let currentPage = 1;
let scheduleList: AiringSchedule[] = [];
let shouldContinue = true;
do {
const { Page } = await client.request(GetUpcomingTitlesQuery, {
page: currentPage++,
airingAtLowerBound: lastCheckedScheduleAt,
airingAtUpperBound: twoDaysFromNow,
});
const { airingSchedules, pageInfo } = Page!;
scheduleList = scheduleList.concat(
airingSchedules!.filter(
(schedule): schedule is AiringSchedule => !!schedule,
),
);
shouldContinue = pageInfo?.hasNextPage ?? false;
} while (shouldContinue);
if (scheduleList.length === 0) {
return [];
}
await setValue(
env,
"schedule_last_checked_at",
scheduleList[scheduleList.length - 1].airingAt.toString(),
);
return scheduleList;
}

View File

@@ -0,0 +1,75 @@
import { Hono } from "hono";
import { env } from "hono/adapter";
import mapKeys from "lodash.mapkeys";
import { DateTime } from "luxon";
import { Case, changeStringCase } from "~/libs/changeStringCase";
import type { AdminSdkCredentials } from "~/libs/fcm/getGoogleAuthToken";
import { sendFcmMessage } from "~/libs/fcm/sendFcmMessage";
import { verifyQstashHeader } from "~/libs/qstash/verifyQstashHeader";
import { readEnvVariable } from "~/libs/readEnvVariable";
import type { Env } from "~/types/env";
import { ErrorResponse, SuccessResponse } from "~/types/schema";
import { getUpcomingTitlesFromAnilist } from "./anilist";
const app = new Hono();
app.post("/titles", async (c) => {
if (
!(await verifyQstashHeader(
env<Env, typeof c>(c, "workerd"),
c.req.header("Upstash-Signature"),
await c.req.text(),
))
) {
return c.json(ErrorResponse, { status: 401 });
}
const titles = await getUpcomingTitlesFromAnilist(
env<Env, typeof c>(c, "workerd"),
);
await Promise.all(
titles.map(async (title) => {
const titleName =
title.media.title?.userPreferred ??
title.media.title?.english ??
"Unknown Title";
return sendFcmMessage(
mapKeys(
readEnvVariable<AdminSdkCredentials>(c.env, "ADMIN_SDK_JSON"),
(_, key) => changeStringCase(key, Case.snake_case, Case.camelCase),
) as unknown as AdminSdkCredentials,
{
topic: "newTitles",
data: {
type: "new_title",
aniListId: title.media.id.toString(),
title: titleName,
airingAt: title.airingAt.toString(),
},
notification: {
title: "New Series Alert",
body: `${titleName} will be released on ${DateTime.fromSeconds(title.airingAt).toRelative({ unit: "days" })}`,
image:
title.media.coverImage?.medium ??
title.media.coverImage?.large ??
title.media.coverImage?.extraLarge ??
undefined,
},
android: {
notification: {
click_action: "HANDLE_FCM_NOTIFICATION",
},
},
},
);
}),
);
return c.json(SuccessResponse, 200);
});
export default app;

View File

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

View File

@@ -56,7 +56,7 @@ export type FcmMessagePayload = {
interface Notification {
title: string;
body: string;
image: string;
image?: string;
}
interface AndroidConfig {

View File

@@ -0,0 +1,24 @@
import { Receiver } from "@upstash/qstash";
import type { Env } from "~/types/env";
export function verifyQstashHeader(
env: Env,
signature: string | undefined,
body: string,
): Promise<boolean> {
if (!signature) {
return Promise.resolve(false);
}
const receiver = new Receiver({
currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY,
nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY,
});
return receiver.verify({
body,
signature,
url: "https://aniplay-v2.rururu.workers.dev",
});
}

31
src/models/kv.ts Normal file
View File

@@ -0,0 +1,31 @@
import { eq } from "drizzle-orm";
import type { Env } from "~/types/env";
import { getDb } from "./db";
import { keyValueTable } from "./schema";
export type Key = (typeof keyValueTable.key.enumValues)[number];
export function getValue(env: Env, key: Key): Promise<string | undefined> {
return getDb(env)
.select()
.from(keyValueTable)
.where(eq(keyValueTable.key, key))
.then((results) => results[0]?.value);
}
export function setValue(env: Env, key: Key, value: string) {
return getDb(env)
.insert(keyValueTable)
.values({ key, value })
.onConflictDoUpdate({ set: { value }, target: [keyValueTable.key] })
.run();
}
export function deleteValue(env: Env, key: Key) {
return getDb(env)
.delete(keyValueTable)
.where(eq(keyValueTable.key, key))
.run();
}

View File

@@ -29,4 +29,9 @@ export const watchStatusTable = sqliteTable(
}),
);
export const tables = [watchStatusTable, deviceTokensTable];
export const keyValueTable = sqliteTable("key_value", {
key: text("key", { enum: ["schedule_last_checked_at"] }).primaryKey(),
value: text("value").notNull(),
});
export const tables = [watchStatusTable, deviceTokensTable, keyValueTable];

5
src/types/env.d.ts vendored
View File

@@ -1,4 +1,4 @@
// Generated by Wrangler on Sat Jun 15 2024 05:15:32 GMT-0400 (Eastern Daylight Time)
// Generated by Wrangler on Mon Sep 02 2024 12:55:01 GMT-0400 (Eastern Daylight Time)
// by running `wrangler types src/types/env.d.ts`
import type { Env as HonoEnv } from "hono";
@@ -8,5 +8,8 @@ interface Env extends HonoEnv, Record<string, unknown> {
QSTASH_URL: string;
ENABLE_ANIFY: string;
ADMIN_SDK_JSON: string;
QSTASH_CURRENT_SIGNING_KEY: string;
QSTASH_NEXT_SIGNING_KEY: string;
QSTASH_TOKEN: string;
TURSO_AUTH_TOKEN: string;
}