diff --git a/src/controllers/auth/anilist/getWatchingTitles.ts b/src/controllers/auth/anilist/getWatchingTitles.ts index 341742d..194ff94 100644 --- a/src/controllers/auth/anilist/getWatchingTitles.ts +++ b/src/controllers/auth/anilist/getWatchingTitles.ts @@ -1,6 +1,8 @@ import { graphql } from "gql.tada"; import { GraphQLClient } from "graphql-request"; +import { sleep } from "~/libs/sleep"; + const GetWatchingTitlesQuery = graphql(` query GetWatchingTitles($userName: String!, $page: Int!) { Page(page: $page, perPage: 50) { @@ -55,8 +57,7 @@ export function getWatchingTitles( username: string, page: number, aniListToken: string, - executionCtx: ExecutionContext, -) { +): Promise { const client = new GraphQLClient("https://graphql.anilist.co/"); return client @@ -73,16 +74,67 @@ export function getWatchingTitles( const response = err.response; if (response.status === 429) { console.log("429, retrying in", response.headers.get("Retry-After")); - executionCtx.waitUntil( - sleep(Number(response.headers.get("Retry-After")!) * 1000), + return sleep(Number(response.headers.get("Retry-After")!) * 1000).then( + () => getWatchingTitles(username, page, aniListToken), ); - return getWatchingTitles(username, page, aniListToken, executionCtx); } throw err; }); } -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +type GetWatchingTitles = { + mediaList: + | ({ + media: { + id: number; + idMal: number | null; + title: { + english: string | null; + userPreferred: string | null; + } | null; + description: string | null; + episodes: number | null; + genres: (string | null)[] | null; + status: + | "FINISHED" + | "RELEASING" + | "NOT_YET_RELEASED" + | "CANCELLED" + | "HIATUS" + | null; + bannerImage: string | null; + averageScore: number | null; + coverImage: { + extraLarge: string | null; + large: string | null; + medium: string | null; + } | null; + countryOfOrigin: unknown; + mediaListEntry: { + id: number; + progress: number | null; + status: + | "CURRENT" + | "REPEATING" + | "PLANNING" + | "COMPLETED" + | "DROPPED" + | "PAUSED" + | null; + } | null; + nextAiringEpisode: { + timeUntilAiring: number; + airingAt: number; + episode: number; + } | null; + } | null; + } | null)[] + | null; + pageInfo: { + currentPage: number | null; + hasNextPage: boolean | null; + perPage: number | null; + total: number | null; + } | null; +}; diff --git a/src/controllers/auth/anilist/index.ts b/src/controllers/auth/anilist/index.ts index 70b5018..38d61c0 100644 --- a/src/controllers/auth/anilist/index.ts +++ b/src/controllers/auth/anilist/index.ts @@ -4,11 +4,11 @@ import { streamSSE } from "hono/streaming"; import { fetchEpisodes } from "~/controllers/episodes/getByAniListId"; import { maybeScheduleNextAiringEpisode } from "~/libs/maybeScheduleNextAiringEpisode"; -import { readEnvVariable } from "~/libs/readEnvVariable"; +import { sleep } from "~/libs/sleep"; import { associateDeviceIdWithUsername } from "~/models/token"; import { setWatchStatus } from "~/models/watchStatus"; import type { Env } from "~/types/env"; -import { EpisodesResponseSchema } from "~/types/episode"; +import { Episode, EpisodesResponseSchema } from "~/types/episode"; import { ErrorResponse, ErrorResponseSchema } from "~/types/schema"; import { Title } from "~/types/title"; @@ -137,7 +137,6 @@ app.openapi(route, async (c) => { user.name!, currentPage++, aniListToken, - c.executionCtx, ); if (!mediaList) { break; @@ -151,7 +150,7 @@ app.openapi(route, async (c) => { } for (const mediaObj of mediaList) { - const media = mediaObj?.media!; + const media = mediaObj?.media; if (!media) { continue; } @@ -189,8 +188,14 @@ app.openapi(route, async (c) => { await fetchEpisodes( media.id, - readEnvVariable(c.env, "ENABLE_ANIFY"), - ).then(({ result: { episodes } }) => { + { ...env(c, "workerd"), ENABLE_ANIFY: "false" }, + true, + ).then(({ result: episodesResult }) => { + const episodes = episodesResult?.episodes; + if (!episodes) { + return; + } + stream.writeSSE({ event: "title", data: JSON.stringify({ title: media, episodes }), @@ -199,15 +204,13 @@ app.openapi(route, async (c) => { }); } - hasNextPage = pageInfo?.hasNextPage ?? false; - hasNextPage = pageInfo?.hasNextPage ?? false; - console.log(hasNextPage); 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"); diff --git a/src/controllers/episodes/getByAniListId/anify.ts b/src/controllers/episodes/getByAniListId/anify.ts index 577a7b3..ffe3e24 100644 --- a/src/controllers/episodes/getByAniListId/anify.ts +++ b/src/controllers/episodes/getByAniListId/anify.ts @@ -1,15 +1,18 @@ -import { z } from "zod"; +import { DateTime } from "luxon"; import { PromiseTimedOutError, promiseTimeout } from "~/libs/promiseTimeout"; +import { readEnvVariable } from "~/libs/readEnvVariable"; import { sortByProperty } from "~/libs/sortByProperty"; - -import type { EpisodesResponse } from "./episode"; +import { getValue, setValue } from "~/models/kv"; +import type { Env } from "~/types/env"; +import type { EpisodesResponse } from "~/types/episode"; export async function getEpisodesFromAnify( - isAnifyEnabled: boolean, + env: Env, aniListId: number, ): Promise { - if (shouldSkipAnify(isAnifyEnabled, aniListId)) { + if (await shouldSkipAnify(env, aniListId)) { + console.log("Skipping Anify for title", aniListId); return null; } @@ -19,9 +22,25 @@ export async function getEpisodesFromAnify( response = await promiseTimeout( fetch(`https://anify.eltik.cc/episodes/${aniListId}`, { signal: abortController.signal, - }).then((res) => res.json()), + }).then((res) => res.json() as Promise), 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( + env, + "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"); @@ -73,11 +92,11 @@ export async function getEpisodesFromAnify( }; } -export function shouldSkipAnify( - isAnifyEnabled: boolean, +export async function shouldSkipAnify( + env: Env, aniListId: number, -): boolean { - if (!isAnifyEnabled) { +): Promise { + if (!readEnvVariable(env, "ENABLE_ANIFY")) { return true; } @@ -92,7 +111,13 @@ export function shouldSkipAnify( return true; } - return false; + return await getValue(env, "anify_killswitch_till").then((dateTime) => { + if (!dateTime) { + return false; + } + + return DateTime.fromISO(dateTime).diffNow().as("minutes") > 0; + }); } interface AnifyEpisodesResponse { diff --git a/src/controllers/episodes/getByAniListId/aniwatch.ts b/src/controllers/episodes/getByAniListId/aniwatch.ts index e93e2c7..3d78249 100644 --- a/src/controllers/episodes/getByAniListId/aniwatch.ts +++ b/src/controllers/episodes/getByAniListId/aniwatch.ts @@ -1,8 +1,10 @@ import { findBestMatchingTitle } from "~/libs/findBestMatchingTitle"; +import { sleep } from "~/libs/sleep"; import { Episode, type EpisodesResponse } from "~/types/episode"; export async function getEpisodesFromAniwatch( aniListId: number, + shouldRetry: boolean = false, ): Promise { try { const animeTitle = await import("~/libs/anilist/getTitle") @@ -48,6 +50,16 @@ export async function getEpisodesFromAniwatch( return { providerId: "aniwatch", episodes }; } catch (error) { + if (shouldRetry && "response" in error && error.response.status === 429) { + console.log( + "429, retrying in", + error.response.headers.get("Retry-After"), + ); + return sleep( + Number(error.response.headers.get("Retry-After")!) * 1000, + ).then(() => getEpisodesFromAniwatch(aniListId)); + } + console.error( new Error( `Error trying to load episodes from aniwatch; aniListId: ${aniListId}`, diff --git a/src/controllers/episodes/getByAniListId/index.ts b/src/controllers/episodes/getByAniListId/index.ts index 6e531f2..ff3963f 100644 --- a/src/controllers/episodes/getByAniListId/index.ts +++ b/src/controllers/episodes/getByAniListId/index.ts @@ -1,4 +1,5 @@ import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; +import { env } from "hono/adapter"; import { fetchFromMultipleSources } from "~/libs/fetchFromMultipleSources"; import { readEnvVariable } from "~/libs/readEnvVariable"; @@ -45,13 +46,13 @@ const app = new OpenAPIHono(); export function fetchEpisodesFromAllProviders( aniListId: number, - isAnifyEnabled: boolean, + env: Env, ): Promise { return Promise.allSettled([ import("./aniwatch").then(({ getEpisodesFromAniwatch }) => getEpisodesFromAniwatch(aniListId), ), - getEpisodesFromAnify(isAnifyEnabled, aniListId), + getEpisodesFromAnify(env, aniListId), ]).then((episodeResults) => episodeResults .filter((result) => result.status === "fulfilled") @@ -60,16 +61,20 @@ export function fetchEpisodesFromAllProviders( ); } -export function fetchEpisodes(aniListId: number, isAnifyEnabled: boolean) { +export function fetchEpisodes( + aniListId: number, + env: Env, + shouldRetry: boolean = false, +) { return fetchFromMultipleSources([ - () => getEpisodesFromAnify(isAnifyEnabled, aniListId), + () => getEpisodesFromAnify(env, aniListId), // () => // import("./consumet").then(({ getEpisodesFromConsumet }) => // getEpisodesFromConsumet(aniListId), // ), () => import("./aniwatch").then(({ getEpisodesFromAniwatch }) => - getEpisodesFromAniwatch(aniListId), + getEpisodesFromAniwatch(aniListId, shouldRetry), ), ]); } @@ -79,7 +84,7 @@ app.openapi(route, async (c) => { const { result: episodes, errorOccurred } = await fetchEpisodes( aniListId, - readEnvVariable(c.env, "ENABLE_ANIFY"), + env(c, "workerd"), ); if (errorOccurred) { diff --git a/src/controllers/episodes/getEpisodeUrl/index.ts b/src/controllers/episodes/getEpisodeUrl/index.ts index 2560d83..77d5bab 100644 --- a/src/controllers/episodes/getEpisodeUrl/index.ts +++ b/src/controllers/episodes/getEpisodeUrl/index.ts @@ -1,4 +1,5 @@ import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; +import { env } from "hono/adapter"; import { readEnvVariable } from "~/libs/readEnvVariable"; import type { Env } from "~/types/env"; @@ -75,12 +76,9 @@ const app = new OpenAPIHono(); export async function fetchEpisodeUrlFromAllProviders( aniListId: number, episodeNumber: number, - isAnifyEnabled: boolean, + env: Env, ) { - const results = await fetchEpisodesFromAllProviders( - aniListId, - isAnifyEnabled, - ); + const results = await fetchEpisodesFromAllProviders(aniListId, env); if (results.length === 0) { return { episodes: null, fetchUrlResult: null }; } @@ -101,7 +99,7 @@ export async function fetchEpisodeUrlFromAllProviders( providerId, episode.id, aniListId, - isAnifyEnabled, + readEnvVariable(env, "ENABLE_ANIFY"), ); if (!urlResult) { episodes = null; @@ -190,7 +188,7 @@ app.openapi(route, async (c) => { const { fetchUrlResult } = await fetchEpisodeUrlFromAllProviders( aniListId, episodeNumber!, - isAnifyEnabled, + env(c, "workerd"), ); if (!fetchUrlResult) { return c.json(ErrorResponse, { status: 404 }); diff --git a/src/controllers/internal/new-episode/index.ts b/src/controllers/internal/new-episode/index.ts index a7649e7..3e1088e 100644 --- a/src/controllers/internal/new-episode/index.ts +++ b/src/controllers/internal/new-episode/index.ts @@ -53,14 +53,10 @@ app.post( ); } - const isAnifyEnabled = readEnvVariable( - env(c, "workerd"), - "ENABLE_ANIFY", - ); const { episodes, fetchUrlResult } = await fetchEpisodeUrlFromAllProviders( aniListId, episodeNumber, - isAnifyEnabled, + env(c, "workerd"), ); if (!episodes) { diff --git a/src/libs/sleep.ts b/src/libs/sleep.ts new file mode 100644 index 0000000..0d7f188 --- /dev/null +++ b/src/libs/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/models/schema.ts b/src/models/schema.ts index 8198bcc..a67eb1f 100644 --- a/src/models/schema.ts +++ b/src/models/schema.ts @@ -30,7 +30,9 @@ export const watchStatusTable = sqliteTable( ); export const keyValueTable = sqliteTable("key_value", { - key: text("key", { enum: ["schedule_last_checked_at"] }).primaryKey(), + key: text("key", { + enum: ["schedule_last_checked_at", "anify_killswitch_till"], + }).primaryKey(), value: text("value").notNull(), });