From 1ff9c72e39fc97e8138fcf58b943ac9dd01d60a2 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 5 Jul 2026 12:28:36 +0200 Subject: [PATCH] feat(discover): add Anime section with seasonal toggle Add a dedicated Anime browse page at /discover/anime, mirroring the Movies/Series sections with the same sort and filter controls. A toggle switches between the keyword-based TMDB catalogue (sorted, filterable) and the current AniList season. Seasonal data is fetched from the AniList GraphQL API, resolved to TMDB ids via the Fribb anime-lists mapping with a TMDB title-search fallback, deduplicated across split-cours, and cached daily. The season is also surfaced as a built-in discover slider on the homepage. --- seerr-api.yml | 99 +++++ server/api/anilist.test.ts | 237 ++++++++++ server/api/anilist.ts | 417 ++++++++++++++++++ server/constants/discover.ts | 7 + server/interfaces/api/discoverInterfaces.ts | 15 + server/lib/cache.ts | 7 +- server/routes/discover.ts | 127 ++++++ .../Discover/DiscoverAnime/index.tsx | 206 +++++++++ .../Discover/DiscoverSliderEdit/index.tsx | 2 + .../Discover/SeasonalAnimeSlider/index.tsx | 46 ++ src/components/Discover/constants.ts | 1 + src/components/Discover/index.tsx | 4 + src/components/Layout/MobileMenu/index.tsx | 9 + src/components/Layout/Sidebar/index.tsx | 8 + src/i18n/locale/en.json | 13 + src/pages/discover/anime.tsx | 8 + 16 files changed, 1205 insertions(+), 1 deletion(-) create mode 100644 server/api/anilist.test.ts create mode 100644 server/api/anilist.ts create mode 100644 src/components/Discover/DiscoverAnime/index.tsx create mode 100644 src/components/Discover/SeasonalAnimeSlider/index.tsx create mode 100644 src/pages/discover/anime.tsx diff --git a/seerr-api.yml b/seerr-api.yml index cd0012b679..aaa005a868 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -6007,6 +6007,60 @@ paths: type: array items: $ref: '#/components/schemas/TvResult' + /discover/anime: + get: + summary: Discover anime series + description: Returns a list of TV shows restricted to the TMDB anime keyword in a JSON object. Accepts the same filters as /discover/tv. + tags: + - search + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: ja + - in: query + name: genre + schema: + type: string + example: 18 + - in: query + name: keywords + schema: + type: string + example: 1,2 + - in: query + name: sortBy + schema: + type: string + example: popularity.desc + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' /discover/tv/language/{language}: get: summary: Discover TV shows by original language @@ -6395,6 +6449,51 @@ paths: type: string title: type: string + /discover/seasonal-anime: + get: + summary: Get currently airing seasonal anime. + description: Returns the current AniList anime season resolved to TMDB series, in AniList popularity order. + tags: + - search + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + responses: + '200': + description: Seasonal anime data returned + content: + application/json: + schema: + type: object + properties: + page: + type: number + totalPages: + type: number + totalResults: + type: number + results: + type: array + items: + type: object + properties: + id: + type: number + example: 1429 + tmdbId: + type: number + example: 1429 + ratingKey: + type: string + mediaType: + type: string + example: tv + title: + type: string /request: get: summary: Get all requests diff --git a/server/api/anilist.test.ts b/server/api/anilist.test.ts new file mode 100644 index 0000000000..29eaef5c48 --- /dev/null +++ b/server/api/anilist.test.ts @@ -0,0 +1,237 @@ +import type { AniListMedia } from '@server/api/anilist'; +import { + buildSearchCandidates, + dedupeByTmdbId, + extractTmdbTvMapping, + getCurrentAnimeSeason, + pickCanonicalMalId, + pickTvSearchResult, +} from '@server/api/anilist'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const makeMedia = ( + romaji: string | null, + english: string | null = null +): AniListMedia => ({ + id: 1, + idMal: null, + title: { romaji, english }, + format: 'TV', + episodes: null, + startDate: { year: null, month: null, day: null }, + coverImage: { large: null }, +}); + +describe('getCurrentAnimeSeason', () => { + it('maps months to AniList seasons', () => { + assert.deepEqual(getCurrentAnimeSeason(new Date('2026-01-15')), { + season: 'WINTER', + year: 2026, + }); + assert.deepEqual(getCurrentAnimeSeason(new Date('2026-03-31')), { + season: 'WINTER', + year: 2026, + }); + assert.deepEqual(getCurrentAnimeSeason(new Date('2026-04-01')), { + season: 'SPRING', + year: 2026, + }); + assert.deepEqual(getCurrentAnimeSeason(new Date('2026-07-05')), { + season: 'SUMMER', + year: 2026, + }); + assert.deepEqual(getCurrentAnimeSeason(new Date('2026-12-31')), { + season: 'FALL', + year: 2026, + }); + }); +}); + +describe('extractTmdbTvMapping', () => { + it('extracts tv id and tmdb season from object form', () => { + assert.deepEqual( + extractTmdbTvMapping({ + anilist_id: 20958, + themoviedb_id: { tv: 1429 }, + season: { tvdb: 2, tmdb: 2 }, + type: 'TV', + }), + { tmdbId: 1429, tmdbSeason: 2 } + ); + }); + + it('ignores movie mappings', () => { + assert.equal( + extractTmdbTvMapping({ + anilist_id: 1, + themoviedb_id: { movie: [128] }, + type: 'MOVIE', + }), + null + ); + }); + + it('handles plain number ids for non-movies', () => { + assert.deepEqual( + extractTmdbTvMapping({ anilist_id: 1, themoviedb_id: 1429, type: 'TV' }), + { tmdbId: 1429, tmdbSeason: undefined } + ); + }); + + it('returns null when no tmdb id exists', () => { + assert.equal( + extractTmdbTvMapping({ anilist_id: 1, themoviedb_id: null }), + null + ); + assert.equal(extractTmdbTvMapping({ anilist_id: 1 }), null); + }); + + it('handles season present without tmdb key', () => { + assert.deepEqual( + extractTmdbTvMapping({ + anilist_id: 1, + themoviedb_id: { tv: 5 }, + season: { tvdb: 1 }, + type: 'TV', + }), + { tmdbId: 5, tmdbSeason: undefined } + ); + }); +}); + +describe('buildSearchCandidates', () => { + it('strips trailing season markers from sequels', () => { + const candidates = buildSearchCandidates(makeMedia('Grand Blue Season 3')); + assert.deepEqual(candidates, ['Grand Blue Season 3', 'Grand Blue']); + }); + + it('strips ordinal season suffixes and roman numerals', () => { + assert.ok( + buildSearchCandidates( + makeMedia('Nige Jouzu no Wakagimi 2nd Season') + ).includes('Nige Jouzu no Wakagimi') + ); + assert.ok( + buildSearchCandidates(makeMedia('Youjo Senki II')).includes('Youjo Senki') + ); + }); + + it('adds the segment before a colon for subtitled sequels', () => { + const candidates = buildSearchCandidates( + makeMedia('Clevatess II: Majuu no Ou to Itsuwari no Yuusha Denshou') + ); + assert.ok(candidates.includes('Clevatess')); + }); + + it('includes the english title and deduplicates', () => { + const candidates = buildSearchCandidates( + makeMedia('Otome Kaijuu Caramelise', 'Otome Kaijuu Caramelise') + ); + assert.deepEqual(candidates, ['Otome Kaijuu Caramelise']); + }); + + it('handles missing titles', () => { + assert.deepEqual(buildSearchCandidates(makeMedia(null, null)), []); + }); +}); + +describe('pickTvSearchResult', () => { + const result = ( + id: number, + original_language: string, + genre_ids: number[] + ) => ({ id, original_language, genre_ids }); + + it('prefers japanese animation results', () => { + const picked = pickTvSearchResult([ + result(1, 'en', [16]), + result(2, 'ja', [18]), + result(3, 'ja', [16, 18]), + ]); + assert.equal(picked?.id, 3); + }); + + it('falls back to any japanese result', () => { + const picked = pickTvSearchResult([ + result(1, 'en', [16]), + result(2, 'ja', [18]), + ]); + assert.equal(picked?.id, 2); + }); + + it('returns undefined without japanese results', () => { + assert.equal(pickTvSearchResult([result(1, 'en', [16])]), undefined); + }); +}); + +describe('pickCanonicalMalId', () => { + it('prefers the earliest season', () => { + assert.equal( + pickCanonicalMalId([ + { malId: 25777, tmdbSeason: 2 }, + { malId: 16498, tmdbSeason: 1 }, + { malId: 99147, tmdbSeason: 3 }, + ]), + 16498 + ); + }); + + it('breaks season ties by lowest mal id', () => { + assert.equal( + pickCanonicalMalId([ + { malId: 500, tmdbSeason: 1 }, + { malId: 100, tmdbSeason: 1 }, + ]), + 100 + ); + }); + + it('treats missing season as last', () => { + assert.equal( + pickCanonicalMalId([{ malId: 900 }, { malId: 800, tmdbSeason: 1 }]), + 800 + ); + }); + + it('prefers the base TV series over season-0 recap movies', () => { + // Real Attack on Titan (TMDB 1429) shape: a season-0 recap movie shares + // the show id but should never win over the base TV series. + assert.equal( + pickCanonicalMalId([ + { malId: 36702, tmdbSeason: 0, isTv: false }, + { malId: 16498, tmdbSeason: 1, isTv: true }, + { malId: 25777, tmdbSeason: 2, isTv: true }, + ]), + 16498 + ); + }); + + it('prefers TV type when seasons tie', () => { + assert.equal( + pickCanonicalMalId([ + { malId: 100, tmdbSeason: 1, isTv: false }, + { malId: 200, tmdbSeason: 1, isTv: true }, + ]), + 200 + ); + }); + + it('returns null for no entries', () => { + assert.equal(pickCanonicalMalId([]), null); + }); +}); + +describe('dedupeByTmdbId', () => { + it('keeps the first entry per tmdb id (split-cours)', () => { + const items = dedupeByTmdbId([ + { anilistId: 16498, tmdbId: 1429, title: 'Attack on Titan' }, + { anilistId: 20958, tmdbId: 1429, title: 'Attack on Titan S2' }, + { anilistId: 21460, tmdbId: 62564, title: 'Mob Psycho 100' }, + ]); + assert.deepEqual( + items.map((item) => item.anilistId), + [16498, 21460] + ); + }); +}); diff --git a/server/api/anilist.ts b/server/api/anilist.ts new file mode 100644 index 0000000000..384c209969 --- /dev/null +++ b/server/api/anilist.ts @@ -0,0 +1,417 @@ +import ExternalAPI from '@server/api/externalapi'; +import TheMovieDb from '@server/api/themoviedb'; +import cacheManager from '@server/lib/cache'; +import logger from '@server/logger'; +import axios from 'axios'; + +const ANILIST_API_URL = 'https://graphql.anilist.co'; +// Community maintained mapping between AniList/MAL/AniDB and TMDB/TVDB ids +// https://github.com/Fribb/anime-lists +const FRIBB_MAPPING_URL = + 'https://raw.githubusercontent.com/Fribb/anime-lists/master/anime-list-mini.json'; + +const CACHE_TTL_SECONDS = 86400; // AniList is heavily rate limited; refresh daily +const MAX_SEASONAL_PAGES = 3; // 3 x 50 covers a full simulcast season +const TMDB_ANIMATION_GENRE_ID = 16; + +export type AnimeSeason = 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; + +export const getCurrentAnimeSeason = ( + date: Date = new Date() +): { season: AnimeSeason; year: number } => { + const seasons: AnimeSeason[] = ['WINTER', 'SPRING', 'SUMMER', 'FALL']; + + return { + season: seasons[Math.floor(date.getMonth() / 3)], + year: date.getFullYear(), + }; +}; + +export interface AniListMedia { + id: number; + idMal: number | null; + title: { + romaji: string | null; + english: string | null; + }; + format: string; + episodes: number | null; + startDate: { + year: number | null; + month: number | null; + day: number | null; + }; + coverImage: { + large: string | null; + }; +} + +interface AniListPageResponse { + data: { + Page: { + pageInfo: { + hasNextPage: boolean; + }; + media: AniListMedia[]; + }; + }; +} + +const SEASONAL_ANIME_QUERY = ` +query ($season: MediaSeason, $year: Int, $page: Int) { + Page(page: $page, perPage: 50) { + pageInfo { hasNextPage } + media(season: $season, seasonYear: $year, type: ANIME, + format_in: [TV, TV_SHORT], sort: POPULARITY_DESC) { + id + idMal + title { romaji english } + format + episodes + startDate { year month day } + coverImage { large } + } + } +}`; + +// Fribb entries we care about. themoviedb_id is { tv: id } for series, +// { movie: [ids] } for movies; season carries the matching TMDB/TVDB season. +interface FribbEntry { + anilist_id?: number; + mal_id?: number; + themoviedb_id?: number | { tv?: number; movie?: number[] } | null; + season?: number | { tmdb?: number; tvdb?: number } | null; + type?: string; +} + +export interface AniListTmdbMapping { + tmdbId: number; + tmdbSeason?: number; +} + +export const extractTmdbTvMapping = ( + entry: FribbEntry +): AniListTmdbMapping | null => { + const tmdb = entry.themoviedb_id; + + let tmdbId: number | undefined; + if (typeof tmdb === 'number' && entry.type !== 'MOVIE') { + tmdbId = tmdb; + } else if (tmdb && typeof tmdb === 'object' && typeof tmdb.tv === 'number') { + tmdbId = tmdb.tv; + } + + if (!tmdbId) { + return null; + } + + const season = entry.season; + const tmdbSeason = + typeof season === 'number' + ? season + : season && typeof season === 'object' + ? season.tmdb + : undefined; + + return { tmdbId, tmdbSeason }; +}; + +export interface FribbMalEntry { + malId: number; + tmdbSeason?: number; + isTv?: boolean; +} + +// A TMDB show groups every AniList/MAL season under one id. To label the whole +// show with a single MAL score, pick the canonical entry: the base series, not +// a recap movie or OVA. Rank by TV type first (movies/OVAs share the show's +// TMDB id but carry season 0), then the earliest real season (season 0 = +// specials, sorted last), then the lowest MAL id as a stable tie-breaker. +const rankMalEntry = (entry: FribbMalEntry): [number, number, number] => [ + entry.isTv ? 0 : 1, + entry.tmdbSeason && entry.tmdbSeason >= 1 + ? entry.tmdbSeason + : Number.MAX_SAFE_INTEGER, + entry.malId, +]; + +export const pickCanonicalMalId = (entries: FribbMalEntry[]): number | null => { + let best: FribbMalEntry | null = null; + for (const entry of entries) { + if (!best) { + best = entry; + continue; + } + const [bt, bs, bm] = rankMalEntry(best); + const [t, s, m] = rankMalEntry(entry); + if (t < bt || (t === bt && (s < bs || (s === bs && m < bm)))) { + best = entry; + } + } + + return best ? best.malId : null; +}; + +// Strips trailing season markers ("2nd Season", "Season 3", "III") so that +// sequel entries can be matched against the single TMDB show. +const stripSeasonSuffix = (title: string): string => { + return title + .replace( + /\s*(?:(?:2nd|3rd|\d+th)\s+season|season\s+\d+|part\s+\d+|(?:II|III|IV|V|VI|VII|VIII|IX|X))\s*$/i, + '' + ) + .trim(); +}; + +export const buildSearchCandidates = (media: AniListMedia): string[] => { + const candidates: string[] = []; + const push = (title?: string | null) => { + const trimmed = title?.trim(); + if (trimmed && !candidates.includes(trimmed)) { + candidates.push(trimmed); + } + }; + + push(media.title.romaji); + push(media.title.english); + if (media.title.romaji) { + push(stripSeasonSuffix(media.title.romaji)); + push(stripSeasonSuffix(media.title.romaji.split(':')[0])); + } + if (media.title.english) { + push(stripSeasonSuffix(media.title.english)); + } + + return candidates; +}; + +interface TvSearchResultLike { + id: number; + original_language: string; + genre_ids: number[]; +} + +export const pickTvSearchResult = ( + results: T[] +): T | undefined => { + const japanese = results.filter( + (result) => result.original_language === 'ja' + ); + + return ( + japanese.find((result) => + result.genre_ids?.includes(TMDB_ANIMATION_GENRE_ID) + ) ?? japanese[0] + ); +}; + +export interface SeasonalAnimeItem { + anilistId: number; + tmdbId: number; + title: string; +} + +export const dedupeByTmdbId = ( + items: SeasonalAnimeItem[] +): SeasonalAnimeItem[] => { + const seen = new Set(); + + return items.filter((item) => { + if (seen.has(item.tmdbId)) { + // Split-cours: two AniList entries can resolve to the same TMDB show + return false; + } + seen.add(item.tmdbId); + return true; + }); +}; + +class AniListAPI extends ExternalAPI { + constructor() { + super( + ANILIST_API_URL, + {}, + { + nodeCache: cacheManager.getCache('anilist').data, + rateLimit: { + maxRequests: 20, + maxRPS: 1, + }, + } + ); + } + + public async getSeasonalAnime( + season: AnimeSeason, + year: number + ): Promise { + const media: AniListMedia[] = []; + + for (let page = 1; page <= MAX_SEASONAL_PAGES; page++) { + const response = await this.post( + '', + { + query: SEASONAL_ANIME_QUERY, + variables: { season, year, page }, + }, + undefined, + CACHE_TTL_SECONDS + ); + + media.push(...response.data.Page.media); + + if (!response.data.Page.pageInfo.hasNextPage) { + break; + } + } + + return media; + } +} + +let mappingCache: { + expiresAt: number; + anilistToTmdb: Map; + tmdbToMal: Map; +} | null = null; + +const loadFribbMappings = async () => { + if (mappingCache && mappingCache.expiresAt > Date.now()) { + return mappingCache; + } + + const response = await axios.get(FRIBB_MAPPING_URL, { + timeout: 30000, + }); + + const anilistToTmdb = new Map(); + const malCandidates = new Map(); + + for (const entry of response.data) { + const resolved = extractTmdbTvMapping(entry); + + if (resolved && entry.anilist_id) { + anilistToTmdb.set(entry.anilist_id, resolved); + } + + if (resolved && entry.mal_id) { + const candidates = malCandidates.get(resolved.tmdbId) ?? []; + candidates.push({ + malId: entry.mal_id, + tmdbSeason: resolved.tmdbSeason, + isTv: entry.type === 'TV', + }); + malCandidates.set(resolved.tmdbId, candidates); + } + } + + const tmdbToMal = new Map(); + for (const [tmdbId, candidates] of malCandidates) { + const malId = pickCanonicalMalId(candidates); + if (malId) { + tmdbToMal.set(tmdbId, malId); + } + } + + logger.debug( + `Loaded ${anilistToTmdb.size} AniList and ${tmdbToMal.size} MAL mappings`, + { label: 'AniList' } + ); + + mappingCache = { + expiresAt: Date.now() + CACHE_TTL_SECONDS * 1000, + anilistToTmdb, + tmdbToMal, + }; + + return mappingCache; +}; + +const getAniListTmdbMap = async (): Promise< + Map +> => { + return (await loadFribbMappings()).anilistToTmdb; +}; + +export const getMalIdFromTmdb = async ( + tmdbId: number +): Promise => { + const { tmdbToMal } = await loadFribbMappings(); + return tmdbToMal.get(tmdbId) ?? null; +}; + +const searchTmdbFallback = async ( + tmdb: TheMovieDb, + media: AniListMedia +): Promise => { + for (const query of buildSearchCandidates(media)) { + const response = await tmdb.searchTvShows({ query }); + const match = pickTvSearchResult(response.results); + + if (match) { + return match.id; + } + } + + return null; +}; + +export const getSeasonalAnimeList = async (): Promise => { + const { season, year } = getCurrentAnimeSeason(); + const cache = cacheManager.getCache('anilist').data; + const cacheKey = `seasonal-resolved-${season}-${year}`; + + const cached = cache.get(cacheKey); + if (cached) { + return cached; + } + + const anilist = new AniListAPI(); + const tmdb = new TheMovieDb(); + + const [seasonal, mapping] = await Promise.all([ + anilist.getSeasonalAnime(season, year), + getAniListTmdbMap(), + ]); + + const resolved = await Promise.all( + seasonal.map(async (media): Promise => { + const title = media.title.english ?? media.title.romaji ?? ''; + + const mapped = mapping.get(media.id); + if (mapped) { + return { anilistId: media.id, tmdbId: mapped.tmdbId, title }; + } + + try { + const tmdbId = await searchTmdbFallback(tmdb, media); + if (tmdbId) { + return { anilistId: media.id, tmdbId, title }; + } + } catch (e) { + logger.debug('TMDB fallback search failed for seasonal anime', { + label: 'AniList', + errorMessage: e.message, + title, + }); + } + + logger.debug('No TMDB mapping found for seasonal anime', { + label: 'AniList', + anilistId: media.id, + title, + }); + return null; + }) + ); + + const items = dedupeByTmdbId( + resolved.filter((item): item is SeasonalAnimeItem => item !== null) + ); + + cache.set(cacheKey, items, CACHE_TTL_SECONDS); + + return items; +}; + +export default AniListAPI; diff --git a/server/constants/discover.ts b/server/constants/discover.ts index fda0682243..c5952b6619 100644 --- a/server/constants/discover.ts +++ b/server/constants/discover.ts @@ -22,6 +22,7 @@ export enum DiscoverSliderType { TMDB_NETWORK, TMDB_MOVIE_STREAMING_SERVICES, TMDB_TV_STREAMING_SERVICES, + SEASONAL_ANIME, } export const defaultSliders: Partial[] = [ @@ -97,4 +98,10 @@ export const defaultSliders: Partial[] = [ isBuiltIn: true, order: 11, }, + { + type: DiscoverSliderType.SEASONAL_ANIME, + enabled: true, + isBuiltIn: true, + order: 12, + }, ]; diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts index 6738cbb5fd..f9c82a9960 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -18,3 +18,18 @@ export interface WatchlistResponse { totalResults: number; results: WatchlistItem[]; } + +export interface SeasonalAnimeResult { + id: number; + ratingKey: string; + tmdbId: number; + mediaType: 'tv'; + title: string; +} + +export interface SeasonalAnimeResponse { + page: number; + totalPages: number; + totalResults: number; + results: SeasonalAnimeResult[]; +} diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 64b5c79ee8..45fe31f4c6 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -10,7 +10,8 @@ export type AvailableCacheIds = | 'plexguid' | 'plextv' | 'plexwatchlist' - | 'tvdb'; + | 'tvdb' + | 'anilist'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -75,6 +76,10 @@ class CacheManager { stdTtl: 21600, checkPeriod: 60 * 30, }), + anilist: new Cache('anilist', 'AniList API', { + stdTtl: 86400, + checkPeriod: 60 * 30, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 7f250e6a6e..0fc9deb529 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,6 +1,8 @@ +import { getSeasonalAnimeList } from '@server/api/anilist'; import PlexTvAPI from '@server/api/plextv'; import type { SortOptions } from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb'; +import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; @@ -9,6 +11,7 @@ import { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; import type { GenreSliderItem, + SeasonalAnimeResponse, WatchlistResponse, } from '@server/interfaces/api/discoverInterfaces'; import { getSettings } from '@server/lib/settings'; @@ -488,6 +491,95 @@ discoverRoutes.get('/tv', async (req, res, next) => { } }); +discoverRoutes.get('/anime', async (req, res, next) => { + const tmdb = createTmdbWithRegionLanguage(req.user); + + try { + const query = ApiQuerySchema.parse(req.query); + const keywords = query.keywords; + const excludeKeywords = query.excludeKeywords; + const data = await tmdb.getDiscoverTv({ + page: Number(query.page), + sortBy: query.sortBy as SortOptions, + language: req.locale ?? query.language, + genre: query.genre, + network: query.network ? Number(query.network) : undefined, + firstAirDateLte: query.firstAirDateLte + ? new Date(query.firstAirDateLte).toISOString().split('T')[0] + : undefined, + firstAirDateGte: query.firstAirDateGte + ? new Date(query.firstAirDateGte).toISOString().split('T')[0] + : undefined, + // 'all' bypasses the server-wide original language setting + originalLanguage: query.language ?? 'all', + keywords: keywords + ? `${keywords},${ANIME_KEYWORD_ID}` + : String(ANIME_KEYWORD_ID), + excludeKeywords, + withRuntimeGte: query.withRuntimeGte, + withRuntimeLte: query.withRuntimeLte, + voteAverageGte: query.voteAverageGte, + voteAverageLte: query.voteAverageLte, + voteCountGte: query.voteCountGte, + voteCountLte: query.voteCountLte, + watchProviders: query.watchProviders, + watchRegion: query.watchRegion, + withStatus: query.status, + certification: query.certification, + certificationGte: query.certificationGte, + certificationLte: query.certificationLte, + certificationCountry: query.certificationCountry, + }); + + const media = await Media.getRelatedMedia( + req.user, + data.results.map((result) => ({ + tmdbId: result.id, + mediaType: MediaType.TV, + })) + ); + + let keywordData: TmdbKeyword[] = []; + if (keywords) { + const splitKeywords = keywords.split(','); + + const keywordResults = await Promise.all( + splitKeywords.map(async (keywordId) => { + return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) }); + }) + ); + + keywordData = keywordResults.filter( + (keyword): keyword is TmdbKeyword => keyword !== null + ); + } + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + keywords: keywordData, + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) + ), + }); + } catch (e) { + logger.debug('Something went wrong retrieving anime series', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve anime series.', + }); + } +}); + discoverRoutes.get<{ language: string }>( '/tv/language/:language', async (req, res, next) => { @@ -919,6 +1011,41 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( } ); +discoverRoutes.get, SeasonalAnimeResponse>( + '/seasonal-anime', + async (req, res, next) => { + const itemsPerPage = 20; + const page = req.query.page ? Number(req.query.page) : 1; + const offset = (page - 1) * itemsPerPage; + + try { + const items = await getSeasonalAnimeList(); + + return res.status(200).json({ + page, + totalPages: Math.ceil(items.length / itemsPerPage), + totalResults: items.length, + results: items.slice(offset, offset + itemsPerPage).map((item) => ({ + id: item.tmdbId, + ratingKey: String(item.anilistId), + tmdbId: item.tmdbId, + mediaType: 'tv' as const, + title: item.title, + })), + }); + } catch (e) { + logger.debug('Something went wrong retrieving seasonal anime', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve seasonal anime.', + }); + } + } +); + discoverRoutes.get, WatchlistResponse>( '/watchlist', async (req, res) => { diff --git a/src/components/Discover/DiscoverAnime/index.tsx b/src/components/Discover/DiscoverAnime/index.tsx new file mode 100644 index 0000000000..0568b3bf4b --- /dev/null +++ b/src/components/Discover/DiscoverAnime/index.tsx @@ -0,0 +1,206 @@ +import Button from '@app/components/Common/Button'; +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import SlideCheckbox from '@app/components/Common/SlideCheckbox'; +import type { FilterOptions } from '@app/components/Discover/constants'; +import { + countActiveFilters, + prepareFilterValues, +} from '@app/components/Discover/constants'; +import FilterSlideover from '@app/components/Discover/FilterSlideover'; +import useDiscover from '@app/hooks/useDiscover'; +import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; +import ErrorPage from '@app/pages/_error'; +import defineMessages from '@app/utils/defineMessages'; +import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; +import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; +import type { SeasonalAnimeResult } from '@server/interfaces/api/discoverInterfaces'; +import type { TvResult } from '@server/models/Search'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Discover.DiscoverAnime', { + discoveranime: 'Anime', + seasonal: 'Seasonal', + activefilters: + '{count, plural, one {# Active Filter} other {# Active Filters}}', + sortPopularityAsc: 'Popularity Ascending', + sortPopularityDesc: 'Popularity Descending', + sortFirstAirDateAsc: 'First Air Date Ascending', + sortFirstAirDateDesc: 'First Air Date Descending', + sortTmdbRatingAsc: 'TMDB Rating Ascending', + sortTmdbRatingDesc: 'TMDB Rating Descending', + sortTitleAsc: 'Title (A-Z) Ascending', + sortTitleDesc: 'Title (Z-A) Descending', +}); + +const SortOptions: Record = { + PopularityAsc: 'popularity.asc', + PopularityDesc: 'popularity.desc', + FirstAirDateAsc: 'first_air_date.asc', + FirstAirDateDesc: 'first_air_date.desc', + TmdbRatingAsc: 'vote_average.asc', + TmdbRatingDesc: 'vote_average.desc', + TitleAsc: 'original_title.asc', + TitleDesc: 'original_title.desc', +} as const; + +const AnimeList = ({ filters }: { filters: FilterOptions }) => { + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/anime', { + ...filters, + }); + + if (error) { + return ; + } + + return ( + 0) + } + onScrollBottom={fetchMore} + /> + ); +}; + +const SeasonalAnimeList = () => { + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + mutate, + } = useDiscover('/api/v1/discover/seasonal-anime'); + + if (error) { + return ; + } + + return ( + 0) + } + onScrollBottom={fetchMore} + mutateParent={mutate} + /> + ); +}; + +const DiscoverAnime = () => { + const intl = useIntl(); + const router = useRouter(); + const [showFilters, setShowFilters] = useState(false); + const preparedFilters = prepareFilterValues(router.query); + const updateQueryParams = useUpdateQueryParams({}); + + const seasonal = router.query.seasonal === 'true'; + + const title = intl.formatMessage(messages.discoveranime); + + return ( + <> + +
+
{title}
+
+
+ + {intl.formatMessage(messages.seasonal)} + + + updateQueryParams('seasonal', seasonal ? undefined : 'true') + } + /> +
+ {!seasonal && ( + <> +
+ + + + +
+ setShowFilters(false)} + show={showFilters} + /> +
+ +
+ + )} +
+
+ {seasonal ? ( + + ) : ( + + )} + + ); +}; + +export default DiscoverAnime; diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx index c24a0591ad..bf851496ae 100644 --- a/src/components/Discover/DiscoverSliderEdit/index.tsx +++ b/src/components/Discover/DiscoverSliderEdit/index.tsx @@ -151,6 +151,8 @@ const DiscoverSliderEdit = ({ return intl.formatMessage(sliderTitles.upcomingtv); case DiscoverSliderType.NETWORKS: return intl.formatMessage(sliderTitles.networks); + case DiscoverSliderType.SEASONAL_ANIME: + return intl.formatMessage(sliderTitles.seasonalanime); case DiscoverSliderType.TMDB_MOVIE_KEYWORD: return intl.formatMessage(sliderTitles.tmdbmoviekeyword); case DiscoverSliderType.TMDB_TV_KEYWORD: diff --git a/src/components/Discover/SeasonalAnimeSlider/index.tsx b/src/components/Discover/SeasonalAnimeSlider/index.tsx new file mode 100644 index 0000000000..b860e2124f --- /dev/null +++ b/src/components/Discover/SeasonalAnimeSlider/index.tsx @@ -0,0 +1,46 @@ +import { sliderTitles } from '@app/components/Discover/constants'; +import Slider from '@app/components/Slider'; +import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; +import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; +import type { SeasonalAnimeResponse } from '@server/interfaces/api/discoverInterfaces'; +import Link from 'next/link'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; + +const SeasonalAnimeSlider = () => { + const intl = useIntl(); + + const { data, error } = useSWR( + '/api/v1/discover/seasonal-anime' + ); + + if ((data && data.results.length === 0) || error) { + return null; + } + + return ( + <> +
+ + {intl.formatMessage(sliderTitles.seasonalanime)} + + +
+ ( + + ))} + /> + + ); +}; + +export default SeasonalAnimeSlider; diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 4ce5e34f66..ab4e43ef8e 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -88,6 +88,7 @@ export const sliderTitles = defineMessages('components.Discover', { tmdbsearch: 'TMDB Search', tmdbmoviestreamingservices: 'TMDB Movie Streaming Services', tmdbtvstreamingservices: 'TMDB TV Streaming Services', + seasonalanime: 'Seasonal Anime', }); export const QueryFilterOptions = z.object({ diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 5638d6fb34..37094085ff 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -10,6 +10,7 @@ import NetworkSlider from '@app/components/Discover/NetworkSlider'; import PlexWatchlistSlider from '@app/components/Discover/PlexWatchlistSlider'; import RecentRequestsSlider from '@app/components/Discover/RecentRequestsSlider'; import RecentlyAddedSlider from '@app/components/Discover/RecentlyAddedSlider'; +import SeasonalAnimeSlider from '@app/components/Discover/SeasonalAnimeSlider'; import StudioSlider from '@app/components/Discover/StudioSlider'; import TvGenreSlider from '@app/components/Discover/TvGenreSlider'; import { sliderTitles } from '@app/components/Discover/constants'; @@ -283,6 +284,9 @@ const Discover = () => { case DiscoverSliderType.NETWORKS: sliderComponent = ; break; + case DiscoverSliderType.SEASONAL_ANIME: + sliderComponent = ; + break; case DiscoverSliderType.TMDB_MOVIE_KEYWORD: sliderComponent = ( , activeRegExp: /^\/discover\/tv$/, }, + { + href: '/discover/anime', + content: intl.formatMessage(menuMessages.browseanime), + svgIcon: , + svgIconSelected: , + activeRegExp: /^\/discover\/anime$/, + }, { href: '/requests', content: intl.formatMessage(menuMessages.requests), diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index 60b32291a6..8b9680d53a 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -10,6 +10,7 @@ import { ExclamationTriangleIcon, EyeSlashIcon, FilmIcon, + FireIcon, SparklesIcon, TvIcon, UsersIcon, @@ -25,6 +26,7 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', { dashboard: 'Discover', browsemovies: 'Movies', browsetv: 'Series', + browseanime: 'Anime', requests: 'Requests', blocklist: 'Blocklist', issues: 'Issues', @@ -71,6 +73,12 @@ const SidebarLinks: SidebarLinkProps[] = [ svgIcon: , activeRegExp: /^\/discover\/tv$/, }, + { + href: '/discover/anime', + messagesKey: 'browseanime', + svgIcon: , + activeRegExp: /^\/discover\/anime$/, + }, { href: '/requests', messagesKey: 'requests', diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 2a880430fc..09430a7722 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -47,6 +47,17 @@ "components.Discover.CreateSlider.starttyping": "Starting typing to search.", "components.Discover.CreateSlider.validationDatarequired": "You must provide a data value.", "components.Discover.CreateSlider.validationTitlerequired": "You must provide a title.", + "components.Discover.DiscoverAnime.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}", + "components.Discover.DiscoverAnime.discoveranime": "Anime", + "components.Discover.DiscoverAnime.seasonal": "Seasonal", + "components.Discover.DiscoverAnime.sortFirstAirDateAsc": "First Air Date Ascending", + "components.Discover.DiscoverAnime.sortFirstAirDateDesc": "First Air Date Descending", + "components.Discover.DiscoverAnime.sortPopularityAsc": "Popularity Ascending", + "components.Discover.DiscoverAnime.sortPopularityDesc": "Popularity Descending", + "components.Discover.DiscoverAnime.sortTitleAsc": "Title (A-Z) Ascending", + "components.Discover.DiscoverAnime.sortTitleDesc": "Title (Z-A) Descending", + "components.Discover.DiscoverAnime.sortTmdbRatingAsc": "TMDB Rating Ascending", + "components.Discover.DiscoverAnime.sortTmdbRatingDesc": "TMDB Rating Descending", "components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Movies", "components.Discover.DiscoverMovieKeyword.keywordMovies": "{keywordTitle} Movies", "components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Movies", @@ -127,6 +138,7 @@ "components.Discover.resetsuccess": "Sucessfully reset discover customization settings.", "components.Discover.resettodefault": "Reset to Default", "components.Discover.resetwarning": "Reset all sliders to default. This will also delete any custom sliders!", + "components.Discover.seasonalanime": "Seasonal Anime", "components.Discover.stopediting": "Stop Editing", "components.Discover.studios": "Studios", "components.Discover.timeWindowDay": "Daily", @@ -228,6 +240,7 @@ "components.Layout.LanguagePicker.displaylanguage": "Display Language", "components.Layout.SearchInput.searchPlaceholder": "Search Movies & Series", "components.Layout.Sidebar.blocklist": "Blocklist", + "components.Layout.Sidebar.browseanime": "Anime", "components.Layout.Sidebar.browsemovies": "Movies", "components.Layout.Sidebar.browsetv": "Series", "components.Layout.Sidebar.dashboard": "Discover", diff --git a/src/pages/discover/anime.tsx b/src/pages/discover/anime.tsx new file mode 100644 index 0000000000..3486c4262b --- /dev/null +++ b/src/pages/discover/anime.tsx @@ -0,0 +1,8 @@ +import DiscoverAnime from '@app/components/Discover/DiscoverAnime'; +import type { NextPage } from 'next'; + +const DiscoverAnimePage: NextPage = () => { + return ; +}; + +export default DiscoverAnimePage;