refactor: cleaned up REST code
Some checks failed
Deploy / Deploy (push) Has been cancelled

also removed any references to Anify
This commit is contained in:
2025-12-06 10:00:26 -05:00
parent ec42ac4026
commit dbc78727bd
74 changed files with 300 additions and 8380 deletions

31
src/context.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { Context as HonoContext } from "hono";
export interface GraphQLContext {
db: D1Database;
deviceId?: string;
aniListToken?: string;
user: { id: number; name: string } | null;
honoContext: HonoContext;
}
export async function createGraphQLContext(
c: HonoContext<Env>,
): Promise<GraphQLContext> {
const deviceId = c.req.header("X-Device-ID");
const aniListToken = c.req.header("X-AniList-Token");
const env = c.env as Env;
let user: GraphQLContext["user"] = null;
if (aniListToken) {
const stub = await env.ANILIST_DO.getByName("GLOBAL");
user = await stub.getUser(aniListToken!);
}
return {
db: env.DB,
deviceId,
aniListToken,
user,
honoContext: c,
};
}

View File

@@ -1,214 +0,0 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { env } from "cloudflare:workers";
import { streamSSE } from "hono/streaming";
import { fetchEpisodes } from "~/controllers/episodes/getByAniListId";
import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode";
import { associateDeviceIdWithUsername } from "~/models/token";
import { setWatchStatus } from "~/models/watchStatus";
import { EpisodesResponseSchema } from "~/types/episode";
import { ErrorResponse, ErrorResponseSchema } from "~/types/schema";
import { Title } from "~/types/title";
import { getUser } from "./getUser";
const UserSchema = z.object({
name: z.string(),
avatar: z.object({
medium: z.string().nullable(),
large: z.string(),
}),
statistics: z.object({
minutesWatched: z.number().openapi({ type: "integer", format: "int64" }),
episodesWatched: z.number().int(),
count: z.number().int(),
meanScore: z.number().openapi({ type: "number", format: "float" }),
}),
});
const route = createRoute({
tags: ["aniplay", "auth"],
summary:
"Authenticate with AniList and return all upcoming and 'currently watching' titles",
operationId: "authenticateAniList",
method: "get",
path: "/",
request: {
headers: z.object({
"x-anilist-token": z.string(),
"x-aniplay-device-id": z.string(),
}),
// Uncomment when testing locally
// headers: z.object({
// "x-anilist-token":
// process.env.NODE_ENV === "production"
// ? z.string()
// : z.string().optional(),
// "x-aniplay-device-id":
// process.env.NODE_ENV === "production"
// ? z.string()
// : z.string().optional(),
// }),
// query: z.object({
// aniListToken: z.string().optional(),
// deviceId: z.string().optional(),
// }),
},
responses: {
200: {
content: {
"text/event-stream": {
schema: z.union([
z.object({ title: Title, episodes: EpisodesResponseSchema }),
UserSchema,
]),
},
},
description: "Streams a list of titles",
},
401: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Failed to authenticate with AniList",
},
500: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Error fetching episodes",
},
},
});
const app = new OpenAPIHono<Cloudflare.Env>();
app.openapi(route, async (c) => {
const deviceId =
c.req.header("X-Aniplay-Device-Id") ?? c.req.query("deviceId");
const aniListToken =
c.req.header("X-AniList-Token") ?? c.req.query("aniListToken");
if (!aniListToken) {
return c.json(ErrorResponse, { status: 401 });
}
let user: Awaited<ReturnType<typeof getUser>>;
try {
user = await getUser(aniListToken);
if (!user) {
return c.json(ErrorResponse, { status: 401 });
}
} catch (error) {
console.error("Failed to authenticate with AniList");
console.error(error);
return c.json(ErrorResponse, { status: 500 });
}
try {
await associateDeviceIdWithUsername(deviceId!, user.name!);
} catch (error) {
console.error("Failed to associate device");
console.error(error);
return c.json(ErrorResponse, { status: 500 });
}
c.header("Content-Type", "text/x-unknown");
c.header("content-encoding", "identity");
c.header("transfer-encoding", "chunked");
return streamSSE(
c,
async (stream) => {
await stream.writeSSE({ event: "user", data: JSON.stringify(user) });
let currentPage = 1;
let hasNextPage = true;
do {
const stub = env.ANILIST_DO.getByName(user.name!);
const { mediaList, pageInfo } = await stub
.getTitles(
user.name!,
currentPage++,
["CURRENT", "PLANNING", "PAUSED", "REPEATING"],
aniListToken,
)
.then((data) => data!);
if (!mediaList) {
break;
}
if (!(pageInfo?.hasNextPage ?? false) && (pageInfo?.total ?? 0) > 0) {
await stream.writeSSE({
event: "count",
data: pageInfo!.total.toString(),
});
}
for (const mediaObj of mediaList) {
const media = mediaObj?.media;
if (!media) {
continue;
}
const mediaListEntry = media.mediaListEntry;
if (mediaListEntry) {
const { wasAdded } = await setWatchStatus(
deviceId!,
media.id,
mediaListEntry.status,
);
if (wasAdded) {
await maybeScheduleNextAiringEpisode(media.id);
}
}
const nextEpisode = media.nextAiringEpisode?.episode;
if (
nextEpisode === 0 ||
nextEpisode === 1 ||
media.status === "NOT_YET_RELEASED"
) {
await stream.writeSSE({
event: "title",
data: JSON.stringify({ title: media, episodes: [] }),
id: media.id.toString(),
});
continue;
}
await fetchEpisodes(media.id, true).then((episodes) => {
if (episodes.length === 0) {
return;
}
return stream.writeSSE({
event: "title",
data: JSON.stringify({ title: media, episodes }),
id: media.id.toString(),
});
});
}
hasNextPage = pageInfo?.hasNextPage ?? false;
console.log(hasNextPage);
} while (hasNextPage);
// send end event instead of closing the connection to let the client know that the stream didn't end abruptly
await stream.writeSSE({ event: "end", data: "end" });
console.log("completed");
},
async (err, stream) => {
console.error("Error occurred in SSE");
console.error(err);
await stream.writeln("An error occurred");
await stream.close();
},
);
});
export default app;

View File

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

View File

@@ -1,133 +0,0 @@
import { DateTime } from "luxon";
import { PromiseTimedOutError, promiseTimeout } from "~/libs/promiseTimeout";
import { readEnvVariable } from "~/libs/readEnvVariable";
import { sortByProperty } from "~/libs/sortByProperty";
import { getValue, setValue } from "~/models/kv";
import type { EpisodesResponse } from "~/types/episode";
export async function getEpisodesFromAnify(
aniListId: number,
): Promise<EpisodesResponse | null> {
if (await shouldSkipAnify(aniListId)) {
console.log("Skipping Anify for title", aniListId);
return null;
}
let response: AnifyEpisodesResponse[] | null = null;
const abortController = new AbortController();
try {
response = await promiseTimeout(
fetch(`https://anify.eltik.cc/episodes/${aniListId}`, {
signal: abortController.signal,
}).then((res) => res.json() as Promise<AnifyEpisodesResponse[]>),
30 * 1000,
);
if ("error" in response) {
const error = response.error;
if (error === "Too many requests") {
console.log(
"Sending too many requests to Anify, setting killswitch until",
DateTime.now().plus({ minutes: 1 }).toISO(),
);
setValue(
"anify_killswitch_till",
DateTime.now().plus({ minutes: 1 }).toISO(),
);
}
return null;
}
} catch (e) {
if (e instanceof PromiseTimedOutError) {
abortController.abort("Loading episodes from Anify timed out");
}
console.error(
`Error trying to load episodes from anify; aniListId: ${aniListId}`,
);
console.error(e);
}
if (!response || response.length === 0) {
return null;
}
const sourcePriority = {
zoro: 1,
gogoanime: 2,
};
const filteredEpisodesData = response
.filter(({ providerId }) => {
if (providerId === "9anime" || providerId === "animepahe") {
return false;
}
if (aniListId == 166873 && providerId === "zoro") {
// Mushoku Tensei: Job Reincarnation S2 Part 2 returns incorrect mapping for Zoro only
return false;
}
return true;
})
.sort(sortByProperty(sourcePriority, "providerId"));
if (filteredEpisodesData.length === 0) {
return null;
}
const selectedEpisodeData = filteredEpisodesData[0];
return {
providerId: selectedEpisodeData.providerId,
episodes: selectedEpisodeData.episodes.map(
({ id, number, description, img, rating, title, updatedAt }) => ({
id,
number,
description,
img,
rating,
title,
updatedAt: updatedAt ?? 0,
}),
),
};
}
export async function shouldSkipAnify(aniListId: number): Promise<boolean> {
if (!readEnvVariable("ENABLE_ANIFY")) {
return true;
}
// Some mappings on Anify are incorrect so they return episodes from a similar title
if (
[
153406, // Tower of God S2
158927, // Spy x Family S2
166873, // Mushoku Tensei: Jobless Reincarnation S2 part 2
163134, // Re:ZERO -Starting Life in Another World- Season 3
163146, // Blue Lock S2
].includes(aniListId)
) {
return true;
}
return await getValue("anify_killswitch_till").then((dateTime) => {
if (!dateTime) {
return false;
}
return DateTime.fromISO(dateTime).diffNow().as("minutes") > 0;
});
}
interface AnifyEpisodesResponse {
providerId: string;
episodes: {
id: string;
isFiller: boolean | undefined;
number: number;
title: string;
img: string | null;
hasDub: boolean;
description: string | null;
rating: number | null;
updatedAt: number | undefined;
}[];
}

View File

@@ -1,74 +0,0 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { EpisodesResponseSchema } from "~/types/episode";
import {
AniListIdQuerySchema,
ErrorResponse,
ErrorResponseSchema,
} from "~/types/schema";
const route = createRoute({
tags: ["aniplay", "episodes"],
summary: "Fetch episodes for a title",
operationId: "fetchEpisodes",
method: "get",
path: "/{aniListId}",
request: {
params: z.object({ aniListId: AniListIdQuerySchema }),
},
responses: {
200: {
content: {
"application/json": {
schema: EpisodesResponseSchema,
},
},
description: "Returns a list of episodes",
},
500: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Error fetching episodes",
},
},
});
const app = new OpenAPIHono<Cloudflare.Env>();
export function fetchEpisodes(aniListId: number, shouldRetry: boolean = false) {
return import("./aniwatch")
.then(({ getEpisodesFromAniwatch }) =>
getEpisodesFromAniwatch(aniListId, shouldRetry),
)
.then((episodeResults) => episodeResults?.episodes ?? []);
}
app.openapi(route, async (c) => {
const aniListId = Number(c.req.param("aniListId"));
// Check if we should use mock data
const { useMockData } = await import("~/libs/useMockData");
if (useMockData()) {
const { mockEpisodes } = await import("~/mocks/mockData");
return c.json({
success: true,
result: { providerId: "aniwatch", episodes: mockEpisodes() },
});
}
const episodes = await fetchEpisodes(aniListId);
if (episodes.length === 0) {
return c.json(ErrorResponse, { status: 404 });
}
return c.json({
success: true,
result: { providerId: "aniwatch", episodes },
});
});
export default app;

View File

@@ -1,64 +0,0 @@
import { sortByProperty } from "~/libs/sortByProperty";
import type { FetchUrlResponse } from "~/types/episode/fetch-url-response";
import { type SkipTime, convertSkipTime } from "./convertSkipTime";
import {
audioPriority,
qualityPriority,
subtitlesPriority,
} from "./priorities";
export async function getSourcesFromAnify(
provider: string,
watchId: string,
aniListId: number,
): Promise<FetchUrlResponse | null> {
const response = await fetch("https://anify.eltik.cc/sources", {
body: JSON.stringify({
watchId,
providerId: provider,
episodeNumber: "1",
id: aniListId.toString(),
subType: "sub",
}),
method: "POST",
}).then((res) => res.json() as Promise<AnifySourcesResponse>);
const { sources, subtitles, audio, intro, outro, headers } = response;
if (!sources || sources.length === 0) {
return null;
}
const source = sources.sort(sortByProperty(qualityPriority, "quality"))[0]
?.url;
subtitles?.sort(sortByProperty(subtitlesPriority, "lang"));
audio?.sort(sortByProperty(audioPriority, "lang"));
return {
source,
audio,
subtitles,
intro: convertSkipTime(intro),
outro: convertSkipTime(outro),
headers: Object.keys(headers ?? {}).length > 0 ? headers : undefined,
};
}
interface AnifySourcesResponse {
sources: VideoSource[];
subtitles: LanguageSource[];
audio: LanguageSource[];
intro: SkipTime;
outro: SkipTime;
headers?: Record<string, string>;
}
interface VideoSource {
url: string;
quality: string;
}
interface LanguageSource {
url: string;
lang: string;
}

View File

@@ -1,146 +0,0 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { FetchUrlResponse } from "~/types/episode/fetch-url-response";
import {
AniListIdQuerySchema,
EpisodeNumberSchema,
ErrorResponse,
ErrorResponseSchema,
} from "~/types/schema";
import { fetchEpisodes } from "../getByAniListId";
const FetchUrlRequest = z.object({ episodeNumber: EpisodeNumberSchema });
const route = createRoute({
tags: ["aniplay", "episodes"],
summary: "Fetch stream URL for an episode",
operationId: "fetchStreamUrl",
method: "post",
path: "/{aniListId}/url",
request: {
params: z.object({ aniListId: AniListIdQuerySchema }),
body: {
content: {
"application/json": {
schema: FetchUrlRequest,
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: FetchUrlResponse,
},
},
description: "Returns a stream URL",
},
400: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Unknown provider",
},
404: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Provider did not return a source",
},
500: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Failed to fetch stream URL from provider",
},
},
});
const app = new OpenAPIHono<Cloudflare.Env>();
export async function fetchEpisodeUrl({
id,
aniListId,
episodeNumber,
}:
| { id: string; aniListId?: number; episodeNumber?: number }
| {
id?: string;
aniListId: number;
episodeNumber: number;
}): Promise<FetchUrlResponse | null> {
try {
let episodeId = id;
if (!id) {
const episodes = await fetchEpisodes(aniListId!);
if (episodes.length === 0) {
console.error(`Failed to fetch episodes for title ${aniListId}`);
return null;
}
const episode = episodes.find(
(episode) => episode.number === episodeNumber,
);
if (!episode) {
console.error(
`Episode ${episodeNumber} not found for title ${aniListId}`,
);
return null;
}
episodeId = episode.id;
}
const result = await import("./aniwatch").then(
({ getSourcesFromAniwatch }) => getSourcesFromAniwatch(episodeId!),
);
if (!result) {
return null;
}
return result;
} catch (e) {
console.error("Failed to fetch download URL from Aniwatch", e);
throw e;
}
}
app.openapi(route, async (c) => {
const aniListId = Number(c.req.param("aniListId"));
const { episodeNumber } = await c.req.json<typeof FetchUrlRequest._type>();
if (episodeNumber == undefined) {
return c.json(ErrorResponse, { status: 400 });
}
// Check if we should use mock data
const { useMockData } = await import("~/libs/useMockData");
if (useMockData()) {
const { mockEpisodeUrl } = await import("~/mocks/mockData");
return c.json({ success: true, result: mockEpisodeUrl });
}
try {
console.log(
`Fetching episode URL for aniListId: ${aniListId}, episodeNumber: ${episodeNumber}`,
);
const fetchUrlResult = await fetchEpisodeUrl({ aniListId, episodeNumber });
if (!fetchUrlResult) {
return c.json(ErrorResponse, { status: 404 });
}
return c.json({ success: true, result: fetchUrlResult });
} catch (error) {
return c.json(ErrorResponse, { status: 500 });
}
});
export default app;

View File

@@ -1,20 +0,0 @@
import { OpenAPIHono } from "@hono/zod-openapi";
const app = new OpenAPIHono();
app.route(
"/",
await import("./getByAniListId").then((controller) => controller.default),
);
app.route(
"/",
await import("./getEpisodeUrl").then((controller) => controller.default),
);
app.route(
"/",
await import("./markEpisodeAsWatched").then(
(controller) => controller.default,
),
);
export default app;

View File

@@ -1,103 +0,0 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { env } from "hono/adapter";
import { updateWatchStatus } from "~/controllers/watch-status";
import {
AniListIdQuerySchema,
EpisodeNumberSchema,
ErrorResponse,
ErrorResponseSchema,
SuccessResponseSchema,
} from "~/types/schema";
import { User } from "~/types/user";
import { markEpisodeAsWatched } from "./anilist";
const MarkEpisodeAsWatchedRequest = z.object({
episodeNumber: EpisodeNumberSchema,
isComplete: z.boolean(),
});
const route = createRoute({
tags: ["aniplay", "episodes"],
summary: "Mark episode as watched",
operationId: "markEpisodeAsWatched",
method: "post",
path: "/{aniListId}/watched",
request: {
params: z.object({ aniListId: AniListIdQuerySchema }),
body: {
content: {
"application/json": {
schema: MarkEpisodeAsWatchedRequest,
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: SuccessResponseSchema(User),
},
},
description: "Returns whether the episode was marked as watched",
},
401: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Unauthorized to mark the episode as watched",
},
500: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Error marking episode as watched",
},
},
});
const app = new OpenAPIHono<Cloudflare.Env>();
app.openapi(route, async (c) => {
const aniListToken = c.req.header("X-AniList-Token");
if (!aniListToken) {
return c.json(ErrorResponse, { status: 401 });
}
const deviceId = c.req.header("X-Aniplay-Device-Id")!;
const aniListId = Number(c.req.param("aniListId"));
const { episodeNumber, isComplete } =
await c.req.json<typeof MarkEpisodeAsWatchedRequest._type>();
try {
const user = await markEpisodeAsWatched(
aniListToken,
aniListId,
episodeNumber,
isComplete,
);
if (isComplete) {
await updateWatchStatus(deviceId, aniListId, "COMPLETED");
}
if (!user) {
console.error("Failed to mark episode as watched - user not found?");
return c.json(ErrorResponse, { status: 500 });
}
return c.json({ success: true, result: user }, 200);
} catch (error) {
console.error("Failed to mark episode as watched");
console.error(error);
return c.json(ErrorResponse, { status: 500 });
}
});
export default app;

View File

@@ -1,11 +0,0 @@
import { describe, expect, it } from "bun:test";
import app from "~/index";
describe("Health Check", () => {
it("should return { success: true }", async () => {
const res = await app.request("/");
expect(res.json()).resolves.toEqual({ success: true });
});
});

View File

@@ -1,9 +0,0 @@
import { Hono } from "hono";
import { SuccessResponse } from "~/types/schema";
const app = new Hono();
app.get("/", (c) => c.json(SuccessResponse, 200));
export default app;

View File

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

View File

@@ -1,75 +0,0 @@
import { env } from "cloudflare:workers";
import type { HonoRequest } from "hono";
import { DateTime } from "luxon";
import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode";
import { getValue, setValue } from "~/models/kv";
import { filterUnreleasedTitles } from "~/models/unreleasedTitles";
import type { Title } from "~/types/title";
type AiringSchedule = {
media: Title;
episode: number;
timeUntilAiring: number;
airingAt: number;
id: number;
};
export async function getUpcomingTitlesFromAnilist(req: HonoRequest) {
const durableObjectId = env.ANILIST_DO.idFromName("GLOBAL");
const stub = env.ANILIST_DO.get(durableObjectId);
const lastCheckedScheduleAt = await getValue("schedule_last_checked_at").then(
(value) => (value ? Number(value) : DateTime.now().toUnixInteger()),
);
const twoDaysFromNow = DateTime.now().plus({ days: 2 }).toUnixInteger();
let currentPage = 1;
let plannedToWatchTitles = new Set<number>();
let scheduleList: AiringSchedule[] = [];
let shouldContinue = true;
do {
const Page = await stub.getUpcomingTitles(
currentPage++,
lastCheckedScheduleAt,
twoDaysFromNow,
);
if (!Page) break;
const { airingSchedules, pageInfo } = Page;
plannedToWatchTitles = plannedToWatchTitles.union(
await filterUnreleasedTitles(
airingSchedules!.map((schedule: any) => schedule!.media?.id!),
),
);
scheduleList = scheduleList.concat(
airingSchedules!.filter(
(schedule: any): schedule is AiringSchedule =>
!!schedule &&
!plannedToWatchTitles.has(schedule.media?.id) &&
schedule.media?.countryOfOrigin === "JP" &&
schedule.episode == 1,
),
);
shouldContinue = pageInfo?.hasNextPage ?? false;
} while (shouldContinue);
await Promise.all(
Array.from(plannedToWatchTitles).map((titleId) =>
maybeScheduleNextAiringEpisode(titleId),
),
);
if (scheduleList.length === 0) {
return [];
}
await setValue(
"schedule_last_checked_at",
scheduleList[scheduleList.length - 1].airingAt.toString(),
);
return scheduleList;
}

View File

@@ -1,51 +0,0 @@
import { Hono } from "hono";
import { DateTime } from "luxon";
import { getAdminSdkCredentials } from "~/libs/gcloud/getAdminSdkCredentials";
import { sendFcmMessage } from "~/libs/gcloud/sendFcmMessage";
import { SuccessResponse } from "~/types/schema";
import { getUpcomingTitlesFromAnilist } from "./anilist";
const app = new Hono();
app.post("/", async (c) => {
const titles = await getUpcomingTitlesFromAnilist(c.req);
await Promise.allSettled(
titles.map(async (title) => {
const titleName =
title.media.title?.userPreferred ??
title.media.title?.english ??
"Unknown Title";
return sendFcmMessage(getAdminSdkCredentials(), {
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 ${DateTime.fromSeconds(title.airingAt).toRelative({ unit: ["hours", "minutes"] })}`,
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

@@ -1,56 +0,0 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { ErrorResponse, SuccessResponseSchema } from "~/types/schema";
import { HomeTitle } from "~/types/title/homeTitle";
import { fetchPopularTitlesFromAnilist } from "./anilist";
const BrowsePopularResponse = SuccessResponseSchema(
z.object({
trending: z.array(HomeTitle),
popular: z.array(HomeTitle),
upcoming: z.array(HomeTitle).optional(),
}),
);
const app = new OpenAPIHono();
const route = createRoute({
tags: ["aniplay", "title"],
operationId: "browsePopularTitles",
summary: "Get a preview of popular titles",
method: "get",
path: "/",
request: {
query: z.object({
limit: z
.number({ coerce: true })
.int()
.default(10)
.describe("The number of titles to return"),
}),
},
responses: {
200: {
content: {
"application/json": {
schema: BrowsePopularResponse,
},
},
description: "Returns an object containing a preview of popular titles",
},
},
});
app.openapi(route, async (c) => {
const limit = Number(c.req.query("limit") ?? 10);
const response = await fetchPopularTitlesFromAnilist(limit);
if (!response) {
return c.json(ErrorResponse, { status: 500 });
}
return c.json({ success: true, result: response });
});
export default app;

View File

@@ -1,67 +0,0 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import {
ErrorResponse,
PaginatedResponseSchema,
SuccessResponseSchema,
} from "~/types/schema";
import { HomeTitle } from "~/types/title/homeTitle";
import { fetchPopularTitlesFromAnilist } from "./anilist";
import { PopularCategory } from "./enum";
const BrowsePopularResponse = PaginatedResponseSchema(HomeTitle);
const app = new OpenAPIHono();
const route = createRoute({
tags: ["aniplay", "title"],
operationId: "browsePopularTitlesWithCategory",
summary: "Get a preview of popular titles for a category",
method: "get",
path: "/{category}",
request: {
query: z.object({
limit: z
.number({ coerce: true })
.int()
.default(10)
.describe("The number of titles to return"),
page: z.number({ coerce: true }).int().min(1).default(1),
}),
params: z.object({ category: PopularCategory }),
},
responses: {
200: {
content: {
"application/json": {
schema: BrowsePopularResponse,
},
},
description: "Returns an object containing a preview of popular titles",
},
},
});
app.openapi(route, async (c) => {
const page = Number(c.req.query("page") ?? 1);
const limit = Number(c.req.query("limit") ?? 10);
const popularCategory = c.req.param("category") as PopularCategory;
const response = await fetchPopularTitlesFromAnilist(
popularCategory,
page,
limit,
);
if (!response) {
return c.json(ErrorResponse, { status: 500 });
}
return c.json({
success: true,
results: response.results,
hasNextPage: response.hasNextPage ?? false,
});
});
export default app;

View File

@@ -1,15 +0,0 @@
import { OpenAPIHono } from "@hono/zod-openapi";
const app = new OpenAPIHono();
app.route(
"/browse",
await import("./browse").then((controller) => controller.default),
);
app.route(
"/",
await import("./category").then((controller) => controller.default),
);
export default app;

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from "bun:test";
import app from "~/index";
import { server } from "~/mocks";
server.listen();
describe('requests the "/search" route', () => {
it("valid query that returns anilist results", async () => {
const response = await app.request("/search?query=search query");
expect(response.json()).resolves.toMatchSnapshot();
});
it("query that returns no results", async () => {
const response = await app.request("/search?query=a");
expect(response.json()).resolves.toEqual({
success: true,
results: [],
hasNextPage: false,
});
expect(response.status).toBe(200);
});
});

View File

@@ -1,84 +0,0 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { fetchFromMultipleSources } from "~/libs/fetchFromMultipleSources";
import { PaginatedResponseSchema } from "~/types/schema";
import { HomeTitle } from "~/types/title/homeTitle";
import { fetchSearchResultsFromAnilist } from "./anilist";
const app = new OpenAPIHono();
const route = createRoute({
tags: ["aniplay", "title"],
operationId: "search",
summary: "Search for a title",
method: "get",
path: "/",
request: {
query: z.object({
query: z.string(),
page: z.number({ coerce: true }).int().min(1).default(1),
limit: z.number({ coerce: true }).int().default(10),
}),
},
responses: {
200: {
content: {
"application/json": {
schema: PaginatedResponseSchema(HomeTitle),
},
},
description: "Returns a list of paginated results for the query",
},
},
});
app.openapi(route, async (c) => {
const query = c.req.query("query") ?? "";
const page = Number(c.req.query("page") ?? 1);
const limit = Number(c.req.query("limit") ?? 10);
// Check if we should use mock data
const { useMockData } = await import("~/libs/useMockData");
if (useMockData()) {
const { mockSearchResults } = await import("~/mocks/mockData");
// Paginate mock results
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedResults = mockSearchResults.slice(startIndex, endIndex);
const hasNextPage = endIndex < mockSearchResults.length;
return c.json(
{
success: true,
results: paginatedResults,
hasNextPage,
},
200,
);
}
const { result: response, errorOccurred } = await fetchFromMultipleSources([
() => fetchSearchResultsFromAnilist(query, page, limit),
]);
if (!response) {
return c.json({
success: !errorOccurred,
results: [],
hasNextPage: false,
});
}
return c.json(
{
success: true,
results: response.results,
hasNextPage: response.hasNextPage ?? false,
},
200,
);
});
export default app;

View File

@@ -1,703 +0,0 @@
// Bun Snapshot v1, https://goo.gl/fbAQLP
exports[`requests the "/title" route with a valid id & token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": {
"id": 402665918,
"progress": 1,
"status": "CURRENT",
},
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id but no token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": null,
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id & token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": {
"id": 402665918,
"progress": 1,
"status": "CURRENT",
},
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id but no token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": null,
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id & token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": {
"id": 402665918,
"progress": 1,
"status": "CURRENT",
},
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id but no token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": null,
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id & token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": {
"id": 402665918,
"progress": 1,
"status": "CURRENT",
},
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id but no token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": null,
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id & token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": {
"id": 402665918,
"progress": 1,
"status": "CURRENT",
},
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id but no token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": null,
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id & token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": {
"id": 402665918,
"progress": 1,
"status": "CURRENT",
},
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id but no token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": null,
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id & token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": {
"id": 402665918,
"progress": 1,
"status": "CURRENT",
},
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id but no token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": null,
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id & token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": {
"id": 402665918,
"progress": 1,
"status": "CURRENT",
},
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id but no token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": null,
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id & token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": {
"id": 402665918,
"progress": 1,
"status": "CURRENT",
},
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;
exports[`requests the "/title" route with a valid id but no token 1`] = `
{
"result": {
"averageScore": 66,
"bannerImage": "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg",
"countryOfOrigin": "JP",
"coverImage": {
"extraLarge": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg",
"large": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg",
"medium": "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg",
},
"description":
"Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"
<br><br>
The pages of Grimms' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.
<br><br>
(Source: Netflix Anime)"
,
"episodes": 6,
"genres": [
"Fantasy",
"Thriller",
],
"id": 135643,
"idMal": 49210,
"mediaListEntry": null,
"nextAiringEpisode": null,
"status": "FINISHED",
"title": {
"english": "The Grimm Variations",
"userPreferred": "The Grimm Variations",
},
},
"success": true,
}
`;

View File

@@ -1,31 +0,0 @@
import { describe, expect, it } from "bun:test";
import app from "~/index";
import { server } from "~/mocks";
server.listen();
describe('requests the "/title" route', () => {
it("with a valid id & token", async () => {
const response = await app.request("/title?id=10", {
headers: new Headers({ "x-anilist-token": "asd" }),
});
expect(response.json()).resolves.toMatchSnapshot();
expect(response.status).toBe(200);
});
it("with a valid id but no token", async () => {
const response = await app.request("/title?id=10");
expect(response.json()).resolves.toMatchSnapshot();
expect(response.status).toBe(200);
});
it("with an unknown title from all sources", async () => {
const response = await app.request("/title?id=-1");
expect(response.json()).resolves.toEqual({ success: false });
expect(response.status).toBe(404);
});
});

View File

@@ -1,73 +0,0 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { fetchTitleFromAnilist } from "~/libs/anilist/getTitle";
import { fetchFromMultipleSources } from "~/libs/fetchFromMultipleSources";
import {
AniListIdQuerySchema,
ErrorResponse,
ErrorResponseSchema,
SuccessResponseSchema,
} from "~/types/schema";
import { Title } from "~/types/title";
const app = new OpenAPIHono();
const route = createRoute({
tags: ["aniplay", "title"],
operationId: "fetchTitle",
summary: "Fetch title information",
method: "get",
path: "/",
request: {
query: z.object({ id: AniListIdQuerySchema }),
headers: z.object({ "x-anilist-token": z.string().nullish() }),
},
responses: {
200: {
content: {
"application/json": {
schema: SuccessResponseSchema(Title),
},
},
description: "Returns title information",
},
"404": {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Title could not be found",
},
},
});
app.openapi(route, async (c) => {
const aniListId = Number(c.req.query("id"));
const aniListToken = c.req.header("X-AniList-Token");
// Check if we should use mock data
const { useMockData } = await import("~/libs/useMockData");
if (useMockData()) {
const { mockTitleDetails } = await import("~/mocks/mockData");
return c.json({ success: true, result: mockTitleDetails() }, 200);
}
const { result: title, errorOccurred } = await fetchFromMultipleSources([
() => fetchTitleFromAnilist(aniListId, aniListToken ?? undefined),
]);
if (errorOccurred) {
console.error(`Failed to fetch title ${aniListId}`);
return c.json(ErrorResponse, { status: 500 });
}
if (!title) {
return c.json(ErrorResponse, 404);
}
return c.json({ success: true, result: title }, 200);
});
export default app;

View File

@@ -1,175 +0,0 @@
import { eq } from "drizzle-orm";
import { DateTime } from "luxon";
import { beforeEach, describe, expect, it, mock } from "bun:test";
import app from "~/index";
import { getTestDb } from "~/libs/test/getTestDb";
import { resetTestDb } from "~/libs/test/resetTestDb";
import { server } from "~/mocks";
import { deviceTokensTable } from "~/models/schema";
server.listen();
describe("requests the /token route", () => {
const db = getTestDb();
beforeEach(async () => {
await resetTestDb();
mock.module("src/libs/gcloud/verifyFcmToken", () => ({
verifyFcmToken: () => true,
}));
});
it("should succeed", async () => {
const res = await app.request("/token", {
method: "POST",
headers: new Headers({
"Content-Type": "application/json",
}),
body: JSON.stringify({ token: "123", deviceId: "123" }),
});
expect(res.json()).resolves.toEqual({ success: true });
expect(res.status).toBe(200);
});
it("succeeded, db should contain entry", async () => {
const minimumTimestamp = DateTime.now();
await app.request("/token", {
method: "POST",
headers: new Headers({
"Content-Type": "application/json",
}),
body: JSON.stringify({ token: "123", deviceId: "123" }),
});
const row = await db
.select()
.from(deviceTokensTable)
.where(eq(deviceTokensTable.deviceId, "123"))
.get();
expect(row).toEqual({
deviceId: "123",
token: "123",
username: null,
lastConnectedAt: expect.any(String),
});
// since SQL timestamp doesn't support milliseconds, compare to nearest second
expect(
+DateTime.fromSQL(row!.lastConnectedAt!, { zone: "utc" }).startOf(
"second",
),
).toBeGreaterThanOrEqual(+minimumTimestamp.startOf("second"));
});
it("device id already exists in db, should succeed", async () => {
await db
.insert(deviceTokensTable)
.values({ deviceId: "123", token: "123" });
const res = await app.request("/token", {
method: "POST",
headers: new Headers({
"Content-Type": "application/json",
}),
body: JSON.stringify({ token: "124", deviceId: "123" }),
});
expect(res.json()).resolves.toEqual({ success: true });
expect(res.status).toBe(200);
});
it("device id already exists in db, should contain new token", async () => {
const minimumTimestamp = DateTime.now();
await db
.insert(deviceTokensTable)
.values({ deviceId: "123", token: "123" });
await app.request("/token", {
method: "POST",
headers: new Headers({
"Content-Type": "application/json",
}),
body: JSON.stringify({ token: "124", deviceId: "123" }),
});
const row = await db
.select()
.from(deviceTokensTable)
.where(eq(deviceTokensTable.deviceId, "123"))
.get();
expect(row).toEqual({
deviceId: "123",
token: "124",
username: null,
lastConnectedAt: expect.any(String),
});
// since SQL timestamp doesn't support milliseconds, compare to nearest second
expect(
+DateTime.fromSQL(row!.lastConnectedAt!, { zone: "utc" }).startOf(
"second",
),
).toBeGreaterThanOrEqual(+minimumTimestamp.startOf("second"));
});
it("token already exists in db, should not insert new entry", async () => {
await db
.insert(deviceTokensTable)
.values({ deviceId: "123", token: "123" });
await app.request("/token", {
method: "POST",
headers: new Headers({
"Content-Type": "application/json",
}),
body: JSON.stringify({ token: "123", deviceId: "124" }),
});
const row = await db
.select()
.from(deviceTokensTable)
.where(eq(deviceTokensTable.deviceId, "124"))
.get();
expect(row).toBeUndefined();
});
it("token is invalid, should fail", async () => {
mock.module("src/libs/gcloud/verifyFcmToken", () => ({
verifyFcmToken: () => false,
}));
const res = await app.request("/token", {
method: "POST",
headers: new Headers({
"Content-Type": "application/json",
}),
body: JSON.stringify({ token: "123", deviceId: "124" }),
});
expect(res.json()).resolves.toEqual({ success: false });
expect(res.status).toBe(401);
});
it("token is invalid, should not insert new entry", async () => {
mock.module("src/libs/gcloud/verifyFcmToken", () => ({
verifyFcmToken: () => false,
}));
await app.request("/token", {
method: "POST",
headers: new Headers({
"Content-Type": "application/json",
}),
body: JSON.stringify({ token: "123", deviceId: "124" }),
});
const row = await db
.select()
.from(deviceTokensTable)
.where(eq(deviceTokensTable.deviceId, "124"))
.get();
expect(row).toBeUndefined();
});
});

View File

@@ -1,85 +0,0 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { env } from "hono/adapter";
import { getAdminSdkCredentials } from "~/libs/gcloud/getAdminSdkCredentials";
import { verifyFcmToken } from "~/libs/gcloud/verifyFcmToken";
import { saveToken } from "~/models/token";
import {
ErrorResponse,
ErrorResponseSchema,
SuccessResponse,
SuccessResponseSchema,
} from "~/types/schema";
const app = new OpenAPIHono<Env>();
const SaveTokenRequest = z.object({
token: z.string(),
deviceId: z.string(),
});
const SaveTokenResponse = SuccessResponseSchema();
const route = createRoute({
tags: ["aniplay", "notifications"],
operationId: "saveToken",
summary: "Saves FCM token",
method: "post",
path: "/",
request: {
body: {
content: {
"application/json": {
schema: SaveTokenRequest,
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: SaveTokenResponse,
},
},
description: "Saved token successfully",
},
412: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Token already exists",
},
500: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Unknown error occurred",
},
},
});
app.openapi(route, async (c) => {
const { token, deviceId } = await c.req.json<typeof SaveTokenRequest._type>();
try {
const isValidToken = await verifyFcmToken(token, getAdminSdkCredentials());
if (!isValidToken) {
return c.json(ErrorResponse, 401);
}
await saveToken(deviceId, token);
} catch (error) {
console.error("Failed to save token");
console.error(error);
return c.json(ErrorResponse, 500);
}
return c.json(SuccessResponse);
});
export default app;

View File

@@ -1,203 +0,0 @@
import { eq } from "drizzle-orm";
import { beforeEach, describe, expect, it } from "bun:test";
import app from "~/index";
import { getTestDb } from "~/libs/test/getTestDb";
import { getTestEnv } from "~/libs/test/getTestEnv";
import { resetTestDb } from "~/libs/test/resetTestDb";
import { server } from "~/mocks";
import { deviceTokensTable, watchStatusTable } from "~/models/schema";
server.listen();
describe("requests the /watch-status route", () => {
const db = getTestDb();
beforeEach(async () => {
await resetTestDb();
});
it("saving title, deviceId in db, should succeed", async () => {
await db
.insert(deviceTokensTable)
.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,
}),
},
getTestEnv(),
);
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,
}),
},
getTestEnv(),
);
expect(res.json()).resolves.toEqual({ success: false });
expect(res.status).toBe(500);
});
it("saving title, Anilist request fails, should succeed", async () => {
await db
.insert(deviceTokensTable)
.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,
}),
},
getTestEnv(),
);
expect(res.json()).resolves.toEqual({ success: true });
expect(res.status).toBe(200);
});
it("watch status is null, should succeed", async () => {
await db
.insert(deviceTokensTable)
.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: null,
titleId: 10,
}),
},
getTestEnv(),
);
expect(res.json()).resolves.toEqual({ success: true });
expect(res.status).toBe(200);
});
it("watch status is null, title does not exist, should succeed", async () => {
await db
.insert(deviceTokensTable)
.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: null,
titleId: -1,
}),
},
getTestEnv(),
);
expect(res.json()).resolves.toEqual({ success: true });
expect(res.status).toBe(200);
});
it("watch status is null, title exists, fails to delete entry, should succeed", async () => {
await db
.insert(deviceTokensTable)
.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: null,
titleId: 139518,
}),
},
getTestEnv(),
);
expect(res.json()).resolves.toEqual({ success: true });
expect(res.status).toBe(200);
});
it("watch status is null, should delete entry", async () => {
await db
.insert(deviceTokensTable)
.values({ deviceId: "123", token: "asd" });
await db.insert(watchStatusTable).values({ deviceId: "123", titleId: 10 });
await app.request(
"/watch-status",
{
method: "POST",
headers: new Headers({
"x-anilist-token": "asd",
"Content-Type": "application/json",
}),
body: JSON.stringify({
deviceId: "123",
watchStatus: null,
titleId: 10,
}),
},
getTestEnv(),
);
const row = await db
.select()
.from(watchStatusTable)
.where(eq(watchStatusTable.titleId, 10))
.get();
expect(row).toBeUndefined();
});
});

View File

@@ -1,139 +0,0 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import type { HonoRequest } from "hono";
import { AnilistUpdateType } from "~/libs/anilist/updateType.ts";
import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode";
import { buildNewEpisodeTaskId } from "~/libs/tasks/id";
import { queueTask } from "~/libs/tasks/queueTask";
import { removeTask } from "~/libs/tasks/removeTask";
import { setWatchStatus } from "~/models/watchStatus";
import {
AniListIdSchema,
ErrorResponse,
ErrorResponseSchema,
SuccessResponse,
SuccessResponseSchema,
} from "~/types/schema";
import { WatchStatus } from "~/types/title/watchStatus";
import { maybeUpdateWatchStatusOnAnilist } from "./anilist";
const app = new OpenAPIHono<Cloudflare.Env>();
const UpdateWatchStatusRequest = z.object({
deviceId: z.string(),
watchStatus: WatchStatus.nullable(),
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: SuccessResponseSchema(),
},
},
description: "Watch status was successfully updated",
},
500: {
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
description: "Failed to update watch status",
},
},
});
export async function updateWatchStatus(
deviceId: string,
titleId: number,
watchStatus: WatchStatus | null,
) {
const { wasAdded, wasDeleted } = await setWatchStatus(
deviceId,
Number(titleId),
watchStatus,
);
if (wasAdded) {
await maybeScheduleNextAiringEpisode(titleId);
} else if (wasDeleted) {
await removeTask("NEW_EPISODE", buildNewEpisodeTaskId(titleId));
}
}
app.openapi(route, async (c) => {
const {
deviceId,
watchStatus,
titleId,
isRetrying = false,
} = await c.req.json<typeof UpdateWatchStatusRequest._type>();
const aniListToken = c.req.header("X-AniList-Token");
// Check if we should use mock data
const { useMockData } = await import("~/libs/useMockData");
if (useMockData()) {
// Return success immediately without side effects
return c.json(SuccessResponse, { status: 200 });
}
if (!isRetrying) {
try {
await updateWatchStatus(c.req, deviceId, titleId, watchStatus);
} catch (error) {
console.error("Error setting watch status");
console.error(error);
return c.json(ErrorResponse, { status: 500 });
}
}
try {
await maybeUpdateWatchStatusOnAnilist(
Number(titleId),
watchStatus,
aniListToken,
);
} catch (error) {
console.error("Failed to update watch status on Anilist");
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 });
});
export default app;

41
src/graphql.ts Normal file
View File

@@ -0,0 +1,41 @@
import { createSchema, createYoga } from "graphql-yoga";
import { Hono } from "hono";
import { createGraphQLContext } from "./context";
import { resolvers } from "./resolvers";
import { typeDefs } from "./schema";
const schema = createSchema({
typeDefs,
resolvers,
});
const yoga = createYoga({
schema,
graphqlEndpoint: "/graphql",
landingPage: false, // Disable landing page for production
graphiql: {
title: "Aniplay GraphQL API",
},
context: ({ request }) => {
// Extract Hono context from the request
// graphql-yoga passes the raw request, but we need Hono context
// This will be provided when we integrate with Hono
return request as any;
},
});
const app = new Hono<Cloudflare.Env>();
app.all("/", async (c) => {
const graphqlContext = await createGraphQLContext(c);
// Create a custom request object that includes our GraphQL context
const request = c.req.raw.clone();
(request as any).graphqlContext = graphqlContext;
const response = await yoga.fetch(request, graphqlContext);
return response;
});
export default app;

View File

@@ -1,26 +0,0 @@
import type { Context as HonoContext } from "hono";
export interface GraphQLContext {
db: D1Database;
deviceId?: string;
aniListToken?: string;
user: { id: number, name: string } | null;
honoContext: HonoContext;
}
export async function createGraphQLContext(c: HonoContext<Env>): Promise<GraphQLContext> {
const deviceId = c.req.header("X-Device-ID");
const aniListToken = c.req.header("X-AniList-Token");
const env = c.env as Env;
const stub = await env.ANILIST_DO.getByName("GLOBAL");
const user = await stub.getUser(aniListToken!);
return {
db: env.DB,
deviceId,
aniListToken,
user,
honoContext: c,
};
}

View File

@@ -1,41 +0,0 @@
import { createSchema, createYoga } from "graphql-yoga";
import { Hono } from "hono";
import { createGraphQLContext } from "./context";
import { resolvers } from "./resolvers";
import { typeDefs } from "./schema";
const schema = createSchema({
typeDefs,
resolvers,
});
const yoga = createYoga({
schema,
graphqlEndpoint: "/graphql",
landingPage: false, // Disable landing page for production
graphiql: {
title: "Aniplay GraphQL API",
},
context: ({ request }) => {
// Extract Hono context from the request
// graphql-yoga passes the raw request, but we need Hono context
// This will be provided when we integrate with Hono
return request as any;
},
});
const app = new Hono<Cloudflare.Env>();
app.all("/", async (c) => {
const graphqlContext = await createGraphQLContext(c);
// Create a custom request object that includes our GraphQL context
const request = c.req.raw.clone();
(request as any).graphqlContext = graphqlContext;
const response = await yoga.fetch(request, graphqlContext);
return response;
});
export default app;

View File

@@ -1,36 +0,0 @@
import type { GraphQLContext } from "~/graphql/context";
import { GraphQLError } from "graphql";
import { graphql } from "gql.tada";
import { MediaFragment } from "~/types/title/mediaFragment";
import { env } from "cloudflare:workers";
enum HomeCategory {
WATCHING,
PLANNING,
}
export async function home(_parent: any, args: { category: HomeCategory, page?: number }, context: GraphQLContext) {
const { category, page = 1 } = args;
const { user, aniListToken } = context;
let statusFilters: string[] = [];
switch (category) {
case HomeCategory.WATCHING:
statusFilters = ['CURRENT'];
break;
case HomeCategory.PLANNING:
statusFilters = ['PLANNING', 'PAUSED', 'REPEATING'];
break;
}
const stub = await env.ANILIST_DO.getByName("GLOBAL");
const response = await stub.getTitles(user?.name, page, statusFilters, aniListToken);
if (!response) {
throw new GraphQLError(`Failed to fetch ${category} titles`, {
extensions: { code: "INTERNAL_SERVER_ERROR" },
});
}
return response;
}

View File

@@ -1,21 +0,0 @@
import { getUser } from "~/controllers/auth/anilist/getUser";
import type { GraphQLContext } from "~/graphql/context";
import { GraphQLError } from "graphql";
export async function user(_parent: any, _args: {}, context: GraphQLContext) {
const { aniListToken } = context;
if (!aniListToken) {
throw new GraphQLError("Unauthorized", {
extensions: { code: "UNAUTHORIZED" },
});
}
const response = await getUser(aniListToken);
if (!response) {
throw new GraphQLError(`Failed to fetch user`, {
extensions: { code: "INTERNAL_SERVER_ERROR" },
});
}
return response;
}

View File

@@ -1,9 +1,9 @@
import { Hono } from "hono";
import { maybeUpdateLastConnectedAt } from "~/controllers/maybeUpdateLastConnectedAt";
import { onNewEpisode } from "~/jobs/new-episode";
import type { QueueName } from "~/libs/tasks/queueName.ts";
import { maybeUpdateLastConnectedAt } from "~/middleware/maybeUpdateLastConnectedAt";
import { onNewEpisode } from "./controllers/internal/new-episode";
import type { QueueBody } from "./libs/tasks/queueTask";
const app = new Hono<Cloudflare.Env>();

View File

@@ -1,21 +1,11 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import { getEpisodesFromAniwatch } from "~/controllers/episodes/getByAniListId/aniwatch";
import { fetchEpisodeUrl } from "~/controllers/episodes/getEpisodeUrl";
import { getAdminSdkCredentials } from "~/libs/gcloud/getAdminSdkCredentials";
import { sendFcmMessage } from "~/libs/gcloud/sendFcmMessage";
import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode";
import { getTokensSubscribedToTitle } from "~/models/token";
import { isWatchingTitle } from "~/models/watchStatus";
import {
AniListIdSchema,
EpisodeNumberSchema,
SuccessResponse,
} from "~/types/schema";
const app = new Hono();
import { getEpisodesFromAniwatch } from "~/services/episodes/getByAniListId/aniwatch";
import { fetchEpisodeUrl } from "~/services/episodes/getEpisodeUrl";
import { SuccessResponse } from "~/types/schema";
export async function onNewEpisode(aniListId: number, episodeNumber: number) {
console.log(
@@ -56,29 +46,3 @@ export async function onNewEpisode(aniListId: number, episodeNumber: number) {
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);
}
},
);
export default app;

View File

@@ -1,6 +1,6 @@
import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
import { DurableObject } from "cloudflare:workers";
import { type ResultOf } from "gql.tada";
import { $tada, type ResultOf } from "gql.tada";
import { print } from "graphql";
import { z } from "zod";

View File

@@ -1,153 +0,0 @@
import { HttpResponse, http } from "msw";
export function getAnifyEpisodes() {
return http.get(
"https://anify.eltik.cc/episodes/:aniListId",
({ params }) => {
const aniListId = Number(params["aniListId"]);
if (aniListId === 3 || aniListId === 4 || aniListId < 0) {
return HttpResponse.json([]);
}
return HttpResponse.json([
{
providerId: "zoro",
episodes: [
{
id: "/watch/spy-classroom-season-2-18468?ep=103233",
isFiller: false,
number: 1,
title: "Mission: Forgetter I",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
{
id: "/watch/spy-classroom-season-2-18468?ep=103632",
isFiller: false,
number: 2,
title: "Mission: Forgetter II",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
{
id: "/watch/spy-classroom-season-2-18468?ep=104244",
isFiller: false,
number: 3,
title: "Mission: Forgetter III",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
{
id: "/watch/spy-classroom-season-2-18468?ep=104620",
isFiller: false,
number: 4,
title: "Mission: Forgetter IV",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
{
id: "/watch/spy-classroom-season-2-18468?ep=104844",
isFiller: false,
number: 5,
title: "File: Glint",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
{
id: "/watch/spy-classroom-season-2-18468?ep=105761",
isFiller: false,
number: 6,
title: "File: Dreamspeaker Thea",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
{
id: "/watch/spy-classroom-season-2-18468?ep=106135",
isFiller: false,
number: 7,
title: "File: Forgetter Annette",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
{
id: "/watch/spy-classroom-season-2-18468?ep=106518",
isFiller: false,
number: 8,
title: "Mission: Dreamspeaker I",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
{
id: "/watch/spy-classroom-season-2-18468?ep=106606",
isFiller: false,
number: 9,
title: "Mission: Dreamspeaker II",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
{
id: "/watch/spy-classroom-season-2-18468?ep=106981",
isFiller: false,
number: 10,
title: "Mission: Dreamspeaker III",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
{
id: "/watch/spy-classroom-season-2-18468?ep=107176",
isFiller: false,
number: 11,
title: "Mission: Dreamspeaker IV",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
{
id: "/watch/spy-classroom-season-2-18468?ep=107247",
isFiller: false,
number: 12,
title: "File: Flower Garden Lily",
img: null,
hasDub: false,
description: null,
rating: null,
updatedAt: 0,
},
],
},
]);
},
);
}

View File

@@ -1,46 +0,0 @@
import { HttpResponse, http } from "msw";
export function getAnifySources() {
return http.post("https://anify.eltik.cc/sources", async ({ request }) => {
const { id: aniListId } = await request.json();
if (aniListId < 0) {
return HttpResponse.json({ sources: [] });
}
return HttpResponse.json({
sources: [
{
url: "https://proxy.anify.tv/video/jCB57RSXMJNw%252Bl%252F7FyBhTJgxyu4fxWq%252BaNKwhio1LIFFWpAYK7%252F8XSh%252BAuGkDcb9ncmrm8yVcsjzS1idTV1sEjbb0BtANg2FkrmhfZi4%252Bgg%252F1JfCmyBOq9QkhiZYHedLzHQ8Q6aQc2riLeYsblZY7Kgw%252Filz%252BitXh1tUI97Qd1k%253D/%7B%7D/.m3u8",
quality: "360p",
},
{
url: "https://proxy.anify.tv/video/Yo7Z6i%252FaG8OYgX8PODTiATrhzRg640USqkzuH1RalwnianjLBAQnbcW3XxVqci8EZw3f6Ui%252FbBC2BpJUOpqLmHOr8GEK%252BRCAvdbXfQ8m5iip%252FWzmMrYp5tcOE6kcFcrPwm1DGNMhz%252BqX3k1Je8QbiuFofSBsCTfmh83vy4uUBhc%253D/%7B%7D/.m3u8",
quality: "480p",
},
{
url: "https://proxy.anify.tv/video/cqJw05VAzYMnw721FBjS2LG4BTFvwPYYQz9BxZmCy0ZbDMyD4tJGg%252BmsZonVvfDEb%252BL65I8Y9YNCMKB%252BRYkIvpTy9n1dNGp3sTWXk6%252F3nAlhbR8h8iPjbHqaurUhmw5CCV4Po%252BPQuRFubkWdQG2h0n7GqQrv6tn6FfbcoasDiSM%253D/%7B%7D/.m3u8",
quality: "720p",
},
{
url: "https://proxy.anify.tv/video/MZQCOq%252Baw9w6ywreT8qXviX%252B%252B%252Bhisr%252Bp8qWdyEaCphHla9y%252F4afGVnnObG50pzlK8Km7og6l6v68EKKunByKexiLTivV7oOYMklcZL2Dq3wPleeicg93olUBmztLEvwWWLP8nemmEjy%252BcUBhxaSreVJYzOJpH84hSC7glHsOXig%253D/%7B%7D/.m3u8",
quality: "1080p",
},
{
url: "https://proxy.anify.tv/video/8CLGIJg8G3k%252BH%252BYV9xyOYVGZ8al8uZqqtbXk44wKRco%252BGATkCrqlkgdRiam3owmOU4f2MAB89GOblOuZbxifwbGsjvp32uxhRC4kZVYrWnZmP%252FrLxtqwi0n6zY%252BvrffUh6dbg6DADSLCWhd2bNUUIg%253D%253D/%7B%7D/.m3u8",
quality: "default",
},
],
subtitles: [],
audio: [],
intro: {
start: 0,
end: 0,
},
outro: {
start: 0,
end: 0,
},
headers: {},
});
});
}

View File

@@ -1,12 +0,0 @@
import { HttpResponse, http } from "msw";
export function getAnifyTitle() {
return http.get(`https://anify.eltik.cc/info`, ({ request }) => {
// Construct a URL instance out of the intercepted request.
const url = new URL(request.url);
const id = url.searchParams.get("id");
// TODO: Actually return a response
return HttpResponse.json({ bannerImage: null, countryOfOrigin: "JP" });
});
}

View File

@@ -1,6 +1,3 @@
import { getAnifyEpisodes } from "./anify/episodes";
import { getAnifySources } from "./anify/sources";
import { getAnifyTitle } from "./anify/title";
import { deleteAnilistMediaListEntry } from "./anilist/deleteMediaListEntry";
import { getAnilistMediaListEntry } from "./anilist/mediaListEntry";
import { getAnilistNextAiringEpisode } from "./anilist/nextAiringEpisode";
@@ -23,9 +20,6 @@ export const handlers = [
getAnilistSearchResults(),
getAnilistTitle(),
updateAnilistWatchStatus(),
getAnifyEpisodes(),
getAnifySources(),
getAnifyTitle(),
getAniwatchEpisodes(),
getAniwatchSearchResults(),
getAniwatchSources(),

View File

@@ -1,3 +1,4 @@
import type { Episode } from "~/types/episode";
import type { FetchUrlResponseSchema } from "~/types/episode/fetch-url-response";
import type { Title } from "~/types/title";
import type { HomeTitle } from "~/types/title/homeTitle";
@@ -92,12 +93,13 @@ export const mockEpisodeUrl: FetchUrlResponseSchema = {
* Mock data for episodes list
* Returns a sample list of 50 episodes for testing
*/
export const mockEpisodes = () => {
export const mockEpisodes: () => Episode[] = () => {
const randomId = Math.floor(Math.random() * 1000000);
return Array.from({ length: 50 }, (_, i) => ({
id: `${randomId}-episode-${i + 1}`,
number: i + 1,
title: `Episode ${i + 1}`,
isFiller: false,
updatedAt: 0,
}));
};

View File

@@ -31,7 +31,7 @@ export const watchStatusTable = sqliteTable(
export const keyValueTable = sqliteTable("key_value", {
key: text("key", {
enum: ["schedule_last_checked_at", "anify_killswitch_till"],
enum: ["schedule_last_checked_at"],
}).primaryKey(),
value: text("value").notNull(),
});

View File

@@ -11,19 +11,19 @@ import { user } from "./queries/user";
import { Title } from "./title";
export const resolvers = {
Query: {
healthCheck,
title,
search,
popularBrowse,
popularByCategory,
episodeStream,
user,
},
Mutation: {
updateWatchStatus: updateWatchStatusMutation,
markEpisodeAsWatched: markEpisodeAsWatchedMutation,
updateToken: updateTokenMutation,
},
Title,
Query: {
healthCheck,
title,
search,
popularBrowse,
popularByCategory,
episodeStream,
user,
},
Mutation: {
updateWatchStatus: updateWatchStatusMutation,
markEpisodeAsWatched: markEpisodeAsWatchedMutation,
updateToken: updateTokenMutation,
},
Title,
};

View File

@@ -1,8 +1,7 @@
import { GraphQLError } from "graphql";
import { markEpisodeAsWatched } from "~/controllers/episodes/markEpisodeAsWatched/anilist";
import type { GraphQLContext } from "../../context";
import type { GraphQLContext } from "~/context";
import { markEpisodeAsWatched } from "~/services/episodes/markEpisodeAsWatched/anilist";
interface MarkEpisodeAsWatchedInput {
titleId: number;
@@ -39,6 +38,17 @@ export async function markEpisodeAsWatchedMutation(
input.isComplete,
);
if (input.isComplete) {
if (context.deviceId) {
const { updateWatchStatus } = await import("~/services/watch-status");
await updateWatchStatus(context.deviceId, input.titleId, "COMPLETED");
} else {
console.warn(
"Device ID not found in context, skipping watch status update",
);
}
}
if (!user) {
throw new GraphQLError("Failed to mark episode as watched", {
extensions: { code: "INTERNAL_SERVER_ERROR" },

View File

@@ -1,9 +1,8 @@
import type { GraphQLContext } from "~/context";
import { getAdminSdkCredentials } from "~/libs/gcloud/getAdminSdkCredentials";
import { verifyFcmToken } from "~/libs/gcloud/verifyFcmToken";
import { saveToken } from "~/models/token";
import type { GraphQLContext } from "../../context";
export async function updateTokenMutation(
_parent: unknown,
args: { token: string },

View File

@@ -1,10 +1,9 @@
import { GraphQLError } from "graphql";
import { updateWatchStatus } from "~/controllers/watch-status";
import type { GraphQLContext } from "~/context";
import { updateWatchStatus } from "~/services/watch-status";
import type { WatchStatus } from "~/types/title/watchStatus";
import type { GraphQLContext } from "../../context";
interface UpdateWatchStatusInput {
titleId: number;
watchStatus: WatchStatus | null;

View File

@@ -1,6 +1,5 @@
import { fetchEpisodeUrl } from "~/controllers/episodes/getEpisodeUrl";
import type { GraphQLContext } from "../../context";
import type { GraphQLContext } from "~/context";
import { fetchEpisodeUrl } from "~/services/episodes/getEpisodeUrl";
export async function episodeStream(
_parent: unknown,

View File

@@ -1,4 +1,4 @@
import type { GraphQLContext } from "../../context";
import type { GraphQLContext } from "~/context";
export function healthCheck(
_parent: unknown,

View File

@@ -0,0 +1,45 @@
import { env } from "cloudflare:workers";
import { graphql } from "gql.tada";
import { GraphQLError } from "graphql";
import type { GraphQLContext } from "~/graph~/context";
import { MediaFragment } from "~/types/title/mediaFragment";
enum HomeCategory {
WATCHING,
PLANNING,
}
export async function home(
_parent: any,
args: { category: HomeCategory; page?: number },
context: GraphQLContext,
) {
const { category, page = 1 } = args;
const { user, aniListToken } = context;
let statusFilters: string[] = [];
switch (category) {
case HomeCategory.WATCHING:
statusFilters = ["CURRENT"];
break;
case HomeCategory.PLANNING:
statusFilters = ["PLANNING", "PAUSED", "REPEATING"];
break;
}
const stub = await env.ANILIST_DO.getByName("GLOBAL");
const response = await stub.getTitles(
user?.name,
page,
statusFilters,
aniListToken,
);
if (!response) {
throw new GraphQLError(`Failed to fetch ${category} titles`, {
extensions: { code: "INTERNAL_SERVER_ERROR" },
});
}
return response;
}

View File

@@ -1,8 +1,7 @@
import { GraphQLError } from "graphql";
import { fetchPopularTitlesFromAnilist } from "~/controllers/popular/browse/anilist";
import type { GraphQLContext } from "../../context";
import type { GraphQLContext } from "~/context";
import { fetchPopularTitlesFromAnilist } from "~/services/popular/browse/anilist";
interface PopularBrowseArgs {
limit?: number;

View File

@@ -1,9 +1,8 @@
import { GraphQLError } from "graphql";
import { fetchPopularTitlesFromAnilist } from "~/controllers/popular/category/anilist";
import type { PopularCategory } from "~/controllers/popular/category/enum";
import type { GraphQLContext } from "../../context";
import type { GraphQLContext } from "~/context";
import { fetchPopularTitlesFromAnilist } from "~/services/popular/category/anilist";
import type { PopularCategory } from "~/services/popular/category/enum";
interface PopularByCategoryArgs {
category: PopularCategory;

View File

@@ -1,6 +1,5 @@
import { fetchSearchResultsFromAnilist } from "~/controllers/search/anilist";
import type { GraphQLContext } from "../../context";
import type { GraphQLContext } from "~/context";
import { fetchSearchResultsFromAnilist } from "~/services/search/anilist";
interface SearchArgs {
query: string;

View File

@@ -1,9 +1,8 @@
import { GraphQLError } from "graphql";
import type { GraphQLContext } from "~/context";
import { fetchTitleFromAnilist } from "~/libs/anilist/getTitle";
import type { GraphQLContext } from "../../context";
interface TitleArgs {
id: number;
}

View File

@@ -0,0 +1,22 @@
import { GraphQLError } from "graphql";
import type { GraphQLContext } from "~/context";
import { getUser } from "~/services/auth/anilist/getUser";
export async function user(_parent: any, _args: {}, context: GraphQLContext) {
const { aniListToken } = context;
if (!aniListToken) {
throw new GraphQLError("Unauthorized", {
extensions: { code: "UNAUTHORIZED" },
});
}
const response = await getUser(aniListToken);
if (!response) {
throw new GraphQLError(`Failed to fetch user`, {
extensions: { code: "INTERNAL_SERVER_ERROR" },
});
}
return response;
}

View File

@@ -1,4 +1,4 @@
import { fetchEpisodes } from "~/controllers/episodes/getByAniListId";
import { fetchEpisodes } from "~/services/episodes/getByAniListId";
import type { Title as TitleType } from "~/types/title";
import { imageResolver } from "./image";

View File

@@ -0,0 +1,19 @@
import { Episode } from "~/types/episode";
export async function fetchEpisodes(
aniListId: number,
shouldRetry: boolean = false,
): Promise<Episode[]> {
// Check if we should use mock data
const { useMockData } = await import("~/libs/useMockData");
if (useMockData()) {
const { mockEpisodes } = await import("~/mocks/mockData");
return mockEpisodes();
}
return import("./aniwatch")
.then(({ getEpisodesFromAniwatch }) =>
getEpisodesFromAniwatch(aniListId, shouldRetry),
)
.then((episodeResults) => episodeResults?.episodes ?? []);
}

View File

@@ -0,0 +1,50 @@
import { FetchUrlResponse } from "~/types/episode/fetch-url-response";
import { fetchEpisodes } from "../getByAniListId";
export async function fetchEpisodeUrl({
id,
aniListId,
episodeNumber,
}:
| { id: string; aniListId?: number; episodeNumber?: number }
| {
id?: string;
aniListId: number;
episodeNumber: number;
}): Promise<FetchUrlResponse | null> {
try {
let episodeId = id;
if (!id) {
const episodes = await fetchEpisodes(aniListId!);
if (episodes.length === 0) {
console.error(`Failed to fetch episodes for title ${aniListId}`);
return null;
}
const episode = episodes.find(
(episode) => episode.number === episodeNumber,
);
if (!episode) {
console.error(
`Episode ${episodeNumber} not found for title ${aniListId}`,
);
return null;
}
episodeId = episode.id;
}
const result = await import("./aniwatch").then(
({ getSourcesFromAniwatch }) => getSourcesFromAniwatch(episodeId!),
);
if (!result) {
return null;
}
return result;
} catch (e) {
console.error("Failed to fetch download URL from Aniwatch", e);
throw e;
}
}

View File

@@ -5,6 +5,23 @@ export async function fetchSearchResultsFromAnilist(
page: number,
limit: number,
): Promise<SearchResultsResponse | undefined> {
// Check if we should use mock data
const { useMockData } = await import("~/libs/useMockData");
if (useMockData()) {
const { mockSearchResults } = await import("~/mocks/mockData");
// Paginate mock results
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedResults = mockSearchResults.slice(startIndex, endIndex);
const hasNextPage = endIndex < mockSearchResults.length;
return {
results: paginatedResults as any,
hasNextPage,
};
}
const durableObjectId = env.ANILIST_DO.idFromName("GLOBAL");
const stub = env.ANILIST_DO.get(durableObjectId);

View File

@@ -0,0 +1,22 @@
import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode";
import { buildNewEpisodeTaskId } from "~/libs/tasks/id";
import { removeTask } from "~/libs/tasks/removeTask";
import { setWatchStatus } from "~/models/watchStatus";
import { WatchStatus } from "~/types/title/watchStatus";
export async function updateWatchStatus(
deviceId: string,
titleId: number,
watchStatus: WatchStatus | null,
) {
const { wasAdded, wasDeleted } = await setWatchStatus(
deviceId,
Number(titleId),
watchStatus,
);
if (wasAdded) {
await maybeScheduleNextAiringEpisode(titleId);
} else if (wasDeleted) {
await removeTask("NEW_EPISODE", buildNewEpisodeTaskId(titleId));
}
}