From 592cc08853e00d9370f549197c1101f8c5643527 Mon Sep 17 00:00:00 2001 From: Rushil Perera Date: Sun, 27 Oct 2024 13:59:49 -0400 Subject: [PATCH] feat: create routes to load popular titles --- src/controllers/popular/browse/anilist.ts | 157 ++++++++++++++++++ src/controllers/popular/browse/index.ts | 56 +++++++ src/controllers/popular/category/anilist.ts | 105 ++++++++++++ src/controllers/popular/category/enum.ts | 4 + src/controllers/popular/category/index.ts | 59 +++++++ src/controllers/popular/index.ts | 15 ++ src/controllers/popular/mapTitle.ts | 12 ++ src/controllers/search/anilist.ts | 33 ++-- src/controllers/search/index.ts | 4 +- src/index.ts | 6 + src/libs/getCurrentAndNextSeason.spec.ts | 69 ++++++++ src/libs/getCurrentAndNextSeason.ts | 35 ++++ .../title/homeTitle.ts} | 20 ++- 13 files changed, 554 insertions(+), 21 deletions(-) create mode 100644 src/controllers/popular/browse/anilist.ts create mode 100644 src/controllers/popular/browse/index.ts create mode 100644 src/controllers/popular/category/anilist.ts create mode 100644 src/controllers/popular/category/enum.ts create mode 100644 src/controllers/popular/category/index.ts create mode 100644 src/controllers/popular/index.ts create mode 100644 src/controllers/popular/mapTitle.ts create mode 100644 src/libs/getCurrentAndNextSeason.spec.ts create mode 100644 src/libs/getCurrentAndNextSeason.ts rename src/{controllers/search/searchResult.ts => types/title/homeTitle.ts} (51%) diff --git a/src/controllers/popular/browse/anilist.ts b/src/controllers/popular/browse/anilist.ts new file mode 100644 index 0000000..bde7c5a --- /dev/null +++ b/src/controllers/popular/browse/anilist.ts @@ -0,0 +1,157 @@ +import { graphql } from "gql.tada"; +import { GraphQLClient } from "graphql-request"; + +import { getCurrentAndNextSeason } from "~/libs/getCurrentAndNextSeason"; +import { sleep } from "~/libs/sleep"; +import { HomeTitleFragment } from "~/types/title/homeTitle"; + +import { mapTitle } from "../mapTitle"; + +const BrowsePopularQuery = graphql( + ` + query BrowsePopular( + $season: MediaSeason! + $seasonYear: Int! + $nextSeason: MediaSeason! + $nextYear: Int! + $limit: Int! + ) { + trending: Page(page: 1, perPage: $limit) { + media(sort: TRENDING_DESC, type: ANIME, isAdult: false) { + ...HomeTitle + } + } + season: Page(page: 1, perPage: $limit) { + media( + season: $season + seasonYear: $seasonYear + sort: POPULARITY_DESC + type: ANIME + isAdult: false + ) { + ...HomeTitle + } + } + nextSeason: Page(page: 1, perPage: 1) { + media( + season: $nextSeason + seasonYear: $nextYear + sort: START_DATE + type: ANIME + isAdult: false + ) { + nextAiringEpisode { + airingAt + timeUntilAiring + } + } + } + } + `, + [HomeTitleFragment], +); + +const NextSeasonPopularQuery = graphql(` + query NextSeasonPopular( + $nextSeason: MediaSeason + $nextYear: Int + $limit: Int! + ) { + Page(page: 1, perPage: $limit) { + media( + season: $nextSeason + seasonYear: $nextYear + sort: POPULARITY_DESC + type: ANIME + isAdult: false + ) { + ...media + } + } + } + + fragment media on Media { + id + title { + english + userPreferred + } + coverImage { + extraLarge + large + medium + } + } +`); + +export async function fetchPopularTitlesFromAnilist( + limit: number, +): Promise { + const client = new GraphQLClient("https://graphql.anilist.co/"); + const { + current: { season: currentSeason, year: currentYear }, + next: { season: nextSeason, year: nextYear }, + } = getCurrentAndNextSeason(); + + try { + const data = await client.request(BrowsePopularQuery, { + limit, + season: currentSeason, + seasonYear: currentYear, + nextSeason, + nextYear, + }); + if (!data) return undefined; + + const trendingTitles = data.trending?.media?.map((title) => + mapTitle(title), + ); + const popularSeasonTitles = data.season?.media?.map((title) => + mapTitle(title), + ); + + if (!data.nextSeason?.media?.[0]?.nextAiringEpisode) { + return { + trending: trendingTitles, + season: popularSeasonTitles, + }; + } + + return await client + .request(NextSeasonPopularQuery, { + limit, + nextSeason, + nextYear, + }) + .then((data) => ({ + trending: trendingTitles, + season: popularSeasonTitles, + upcoming: data?.Page?.media?.map((title) => mapTitle(title)), + })); + } catch (error) { + const response = error.response; + if (response.status === 429) { + console.log("429, retrying in", response.headers.get("Retry-After")); + return sleep(Number(response.headers.get("Retry-After")!) * 1000).then( + () => fetchPopularTitlesFromAnilist(limit), + ); + } + + throw error; + } +} + +type SearchResultsResponse = { + results: + | ({ + id: number; + title: { userPreferred: string | null; english: string | null } | null; + coverImage: { + extraLarge: string | null; + large: string | null; + medium: string | null; + } | null; + } | null)[] + | null; + hasNextPage: boolean | null | undefined; +}; diff --git a/src/controllers/popular/browse/index.ts b/src/controllers/popular/browse/index.ts new file mode 100644 index 0000000..56f8684 --- /dev/null +++ b/src/controllers/popular/browse/index.ts @@ -0,0 +1,56 @@ +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; diff --git a/src/controllers/popular/category/anilist.ts b/src/controllers/popular/category/anilist.ts new file mode 100644 index 0000000..60dbcdb --- /dev/null +++ b/src/controllers/popular/category/anilist.ts @@ -0,0 +1,105 @@ +import { graphql } from "gql.tada"; +import { GraphQLClient } from "graphql-request"; + +import { getCurrentAndNextSeason } from "~/libs/getCurrentAndNextSeason"; +import { HomeTitleFragment } from "~/types/title/homeTitle"; + +import { mapTitle } from "../mapTitle"; +import type { PopularCategory } from "./enum"; + +const TrendingQuery = graphql( + ` + query Trending($limit: Int!, $page: Int!) { + trending: Page(page: $page, perPage: $limit) { + media(sort: TRENDING_DESC, type: ANIME, isAdult: false) { + ...HomeTitle + } + } + } + `, + [HomeTitleFragment], +); + +const PopularQuery = graphql( + ` + query Popular( + $limit: Int! + $page: Int! + $season: MediaSeason! + $seasonYear: Int! + ) { + Page(page: $page, perPage: $limit) { + media( + season: $season + seasonYear: $seasonYear + sort: POPULARITY_DESC + type: ANIME + isAdult: false + ) { + ...HomeTitle + } + } + } + `, + [HomeTitleFragment], +); + +const UpcomingQuery = graphql( + ` + query Upcoming( + $limit: Int! + $page: Int! + $nextSeason: MediaSeason! + $nextSeasonYear: Int! + ) { + Page(page: $page, perPage: $limit) { + media( + season: $nextSeason + seasonYear: $nextYear + sort: POPULARITY_DESC + type: ANIME + isAdult: false + ) { + ...HomeTitle + } + } + } + `, + [HomeTitleFragment], +); + +export function fetchPopularTitlesFromAnilist( + category: PopularCategory, + page: number, + limit: number, +) { + const client = new GraphQLClient("https://graphql.anilist.co/"); + + const { current, next } = getCurrentAndNextSeason(); + switch (category) { + case "trending": + return client + .request(TrendingQuery, { limit, page }) + .then((data) => data?.trending?.media?.map((title) => mapTitle(title))); + case "popular": + return client + .request(PopularQuery, { + limit, + page, + season: current.season, + seasonYear: current.year, + }) + .then((data) => data?.Page?.media?.map((title) => mapTitle(title))); + case "upcoming": + return client + .request(UpcomingQuery, { + limit, + page, + nextSeason: next.season, + nextSeasonYear: next.year, + }) + .then((data) => data?.Page?.media?.map((title) => mapTitle(title))); + default: + throw new Error(`Unknown category: ${category}`); + } +} diff --git a/src/controllers/popular/category/enum.ts b/src/controllers/popular/category/enum.ts new file mode 100644 index 0000000..e96ec73 --- /dev/null +++ b/src/controllers/popular/category/enum.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; + +export type PopularCategory = z.infer; +export const PopularCategory = z.enum(["trending", "popular", "upcoming"]); diff --git a/src/controllers/popular/category/index.ts b/src/controllers/popular/category/index.ts new file mode 100644 index 0000000..e0d06b6 --- /dev/null +++ b/src/controllers/popular/category/index.ts @@ -0,0 +1,59 @@ +import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; + +import { ErrorResponse, SuccessResponseSchema } from "~/types/schema"; +import { HomeTitle } from "~/types/title/homeTitle"; + +import { fetchPopularTitlesFromAnilist } from "./anilist"; +import { PopularCategory } from "./enum"; + +const BrowsePopularResponse = SuccessResponseSchema(z.array(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, result: response }); +}); + +export default app; diff --git a/src/controllers/popular/index.ts b/src/controllers/popular/index.ts new file mode 100644 index 0000000..e74d001 --- /dev/null +++ b/src/controllers/popular/index.ts @@ -0,0 +1,15 @@ +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; diff --git a/src/controllers/popular/mapTitle.ts b/src/controllers/popular/mapTitle.ts new file mode 100644 index 0000000..88008ed --- /dev/null +++ b/src/controllers/popular/mapTitle.ts @@ -0,0 +1,12 @@ +export function mapTitle( + media: { + title: { english: string | null; userPreferred: string | null } | null; + } | null, +) { + if (!media) return null; + + return { + ...media, + title: media?.title?.userPreferred ?? media?.title?.english, + }; +} diff --git a/src/controllers/search/anilist.ts b/src/controllers/search/anilist.ts index 0ea5dc8..921f208 100644 --- a/src/controllers/search/anilist.ts +++ b/src/controllers/search/anilist.ts @@ -2,28 +2,27 @@ import { graphql } from "gql.tada"; import { GraphQLClient } from "graphql-request"; import { sleep } from "~/libs/sleep"; +import { HomeTitleFragment } from "~/types/title/homeTitle"; -const SearchQuery = graphql(` - query Search($query: String!, $page: Int!, $limit: Int!) { - Page(page: $page, perPage: $limit) { - media(search: $query, type: ANIME, sort: [POPULARITY_DESC, SCORE_DESC]) { - id - title { - userPreferred - english +const SearchQuery = graphql( + ` + query Search($query: String!, $page: Int!, $limit: Int!) { + Page(page: $page, perPage: $limit) { + media( + search: $query + type: ANIME + sort: [POPULARITY_DESC, SCORE_DESC] + ) { + ...HomeTitle } - coverImage { - extraLarge - large - medium + pageInfo { + hasNextPage } } - pageInfo { - hasNextPage - } } - } -`); + `, + [HomeTitleFragment], +); export async function fetchSearchResultsFromAnilist( query: string, diff --git a/src/controllers/search/index.ts b/src/controllers/search/index.ts index d26d677..725bfd9 100644 --- a/src/controllers/search/index.ts +++ b/src/controllers/search/index.ts @@ -2,9 +2,9 @@ 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"; -import { SearchResult } from "./searchResult"; const app = new OpenAPIHono(); @@ -25,7 +25,7 @@ const route = createRoute({ 200: { content: { "application/json": { - schema: PaginatedResponseSchema(SearchResult), + schema: PaginatedResponseSchema(HomeTitle), }, }, description: "Returns a list of paginated results for the query", diff --git a/src/index.ts b/src/index.ts index 143a7ee..392442c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,12 @@ app.route( "/auth", await import("~/controllers/auth").then((controller) => controller.default), ); +app.route( + "/popular", + await import("~/controllers/popular").then( + (controller) => controller.default, + ), +); app.route( "/internal", await import("~/controllers/internal").then( diff --git a/src/libs/getCurrentAndNextSeason.spec.ts b/src/libs/getCurrentAndNextSeason.spec.ts new file mode 100644 index 0000000..14ffcc3 --- /dev/null +++ b/src/libs/getCurrentAndNextSeason.spec.ts @@ -0,0 +1,69 @@ +import { DateTime } from "luxon"; + +import { describe, expect, it } from "bun:test"; + +import { getCurrentAndNextSeason } from "./getCurrentAndNextSeason"; + +describe("getCurrentAndNextSeason", () => { + it("now is current year, next is current year", () => { + const now = DateTime.local(2023, 4, 1) as DateTime; + + const { current, next } = getCurrentAndNextSeason(now); + + expect(current.year).toEqual(2023); + expect(next.year).toEqual(2023); + }); + + it("now is current year, next is next year", () => { + const now = DateTime.local(2023, 11, 1) as DateTime; + + const { current, next } = getCurrentAndNextSeason(now); + + expect(current.year).toEqual(2023); + expect(next.year).toEqual(2024); + }); + + it("months 1 - 3 are currently winter, next is spring", () => { + [1, 2, 3].forEach((month) => { + const now = DateTime.local(2023, month, 1) as DateTime; + + const { current, next } = getCurrentAndNextSeason(now); + + expect(current.season).toEqual("WINTER"); + expect(next.season).toEqual("SPRING"); + }); + }); + + it("months 4 - 6 are currently spring, next is summer", () => { + [4, 5, 6].forEach((month) => { + const now = DateTime.local(2023, month, 1) as DateTime; + + const { current, next } = getCurrentAndNextSeason(now); + + expect(current.season).toEqual("SPRING"); + expect(next.season).toEqual("SUMMER"); + }); + }); + + it("months 7 - 9 are currently summer, next is fall", () => { + [7, 8, 9].forEach((month) => { + const now = DateTime.local(2023, month, 1) as DateTime; + + const { current, next } = getCurrentAndNextSeason(now); + + expect(current.season).toEqual("SUMMER"); + expect(next.season).toEqual("FALL"); + }); + }); + + it("months 10 - 12 are currently fall, next is winter", () => { + [10, 11, 12].forEach((month) => { + const now = DateTime.local(2023, month, 1) as DateTime; + + const { current, next } = getCurrentAndNextSeason(now); + + expect(current.season).toEqual("FALL"); + expect(next.season).toEqual("WINTER"); + }); + }); +}); diff --git a/src/libs/getCurrentAndNextSeason.ts b/src/libs/getCurrentAndNextSeason.ts new file mode 100644 index 0000000..90e747e --- /dev/null +++ b/src/libs/getCurrentAndNextSeason.ts @@ -0,0 +1,35 @@ +import { DateTime } from "luxon"; + +type Season = "WINTER" | "SPRING" | "SUMMER" | "FALL"; + +interface SeasonInfo { + season: Season; + year: number; +} + +export function getCurrentAndNextSeason(now = DateTime.now()): { + current: SeasonInfo; + next: SeasonInfo; +} { + const seasons = ["WINTER", "SPRING", "SUMMER", "FALL"] as const; + const seasonStartingMonth = [1, 4, 7, 10]; + const currentSeasonIndex = seasonStartingMonth.findLastIndex( + (month) => now.month >= month, + ); + const nextSeasonIndex = currentSeasonIndex + 1; + + const currentSeason = seasons[currentSeasonIndex]; + const nextSeason = seasons[nextSeasonIndex % seasons.length]; + const nextYear = nextSeasonIndex === seasons.length ? now.year + 1 : now.year; + + return { + current: { + season: currentSeason, + year: now.year, + }, + next: { + season: nextSeason, + year: nextYear, + }, + }; +} diff --git a/src/controllers/search/searchResult.ts b/src/types/title/homeTitle.ts similarity index 51% rename from src/controllers/search/searchResult.ts rename to src/types/title/homeTitle.ts index 71f8b9f..1ef74cb 100644 --- a/src/controllers/search/searchResult.ts +++ b/src/types/title/homeTitle.ts @@ -1,7 +1,8 @@ import { z } from "@hono/zod-openapi"; +import { graphql } from "gql.tada"; -export type SearchResult = z.infer; -export const SearchResult = z.object({ +export type HomeTitle = z.infer; +export const HomeTitle = z.object({ id: z.number().openapi({ type: "integer", format: "int64" }), title: z.nullable(z.string()), coverImage: z.nullable( @@ -12,3 +13,18 @@ export const SearchResult = z.object({ }), ), }); + +export const HomeTitleFragment = graphql(` + fragment HomeTitle on Media { + id + title { + english + userPreferred + } + coverImage { + extraLarge + large + medium + } + } +`);