@serifpersia/dango 3.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +258 -0
- package/client/dist/assets/AnimeCard-B5A20jAI.js +1 -0
- package/client/dist/assets/AnimeInfo-0huPAq0O.js +1 -0
- package/client/dist/assets/AnimeInfo-BFWtHlPC.css +1 -0
- package/client/dist/assets/AnimeInfoPage-Dqr5QH6R.js +2 -0
- package/client/dist/assets/AnimeMetaDetails-BPvv5xIl.js +1 -0
- package/client/dist/assets/AnimeMetaDetails-BwNQnRCd.css +1 -0
- package/client/dist/assets/Asmr-B6ZiqkmU.js +2 -0
- package/client/dist/assets/Asmr-Cq2ua6Zk.css +1 -0
- package/client/dist/assets/Asmr.module-ChUXDZKx.js +1 -0
- package/client/dist/assets/AsmrResetProgressModal-Cqsoo3XC.js +1 -0
- package/client/dist/assets/AvSyncCalibrator-C1_iBSyL.css +1 -0
- package/client/dist/assets/AvSyncCalibrator-C8ZoIwMY.js +10 -0
- package/client/dist/assets/ErrorMessage-CXoRbrLN.css +1 -0
- package/client/dist/assets/ErrorMessage-z4f4WGgP.js +1 -0
- package/client/dist/assets/Home-DtRtTuI9.css +1 -0
- package/client/dist/assets/Home-thJhPfs1.js +1 -0
- package/client/dist/assets/Insights-DKtoVe6L.css +1 -0
- package/client/dist/assets/Insights-ZlgPrzGD.js +1 -0
- package/client/dist/assets/LibraryListPage-CCiee-Ot.js +1 -0
- package/client/dist/assets/ListeningList-ctAz_6Zi.js +1 -0
- package/client/dist/assets/Manga-BI9WMTx5.js +1 -0
- package/client/dist/assets/Manga-Dp_-l1Pw.css +1 -0
- package/client/dist/assets/Manga.module-BcfOdsNP.js +1 -0
- package/client/dist/assets/MangaInfoPage-DcrEr7Sk.js +1 -0
- package/client/dist/assets/MangaReadPage-SYg63m48.js +1 -0
- package/client/dist/assets/MangaResetProgressModal-DX-1PS4h.js +1 -0
- package/client/dist/assets/Map-CXFqXmsZ.css +1 -0
- package/client/dist/assets/Map-Dj51g5zL.js +1 -0
- package/client/dist/assets/Mature-DJmUUtlF.css +1 -0
- package/client/dist/assets/Mature-Dqh_9SA3.js +1 -0
- package/client/dist/assets/MediaCard-Be1ST9oj.js +1 -0
- package/client/dist/assets/MediaCard-DB6Yjgky.css +1 -0
- package/client/dist/assets/Music-BGH_0voi.css +1 -0
- package/client/dist/assets/Music-zsPwT8_5.js +1 -0
- package/client/dist/assets/OptionListMenu-Drc_vybW.js +1 -0
- package/client/dist/assets/Player-B036Ic3m.css +1 -0
- package/client/dist/assets/Player-BEtAYps2.js +56 -0
- package/client/dist/assets/PlayerRedirect-D70t8VUW.js +1 -0
- package/client/dist/assets/PlayerSettings-Bj6SsmQ2.css +1 -0
- package/client/dist/assets/PlayerSettings-Cwoji-P7.js +1 -0
- package/client/dist/assets/QueueOptionsButton-B6BMq66e.js +1 -0
- package/client/dist/assets/QueueOptionsButton-CM15uQn8.css +1 -0
- package/client/dist/assets/QueueRail-BUYEjdaq.css +1 -0
- package/client/dist/assets/QueueRail-VxpUDBSm.js +1 -0
- package/client/dist/assets/Radio-B2Q_Egj7.js +1 -0
- package/client/dist/assets/Radio-CCHNFN7R.css +1 -0
- package/client/dist/assets/Radio.module-DGSZLpKM.js +1 -0
- package/client/dist/assets/ReadingList-O0QPwXu7.js +1 -0
- package/client/dist/assets/ResetProgressModal-CR_ovLAu.js +1 -0
- package/client/dist/assets/Search-C8e2nlDD.js +1 -0
- package/client/dist/assets/Search-Cs45oAwI.css +1 -0
- package/client/dist/assets/Search.module-CMEai-B-.js +1 -0
- package/client/dist/assets/Settings-AuFxQcJF.css +1 -0
- package/client/dist/assets/Settings-BgPWBl2V.js +21 -0
- package/client/dist/assets/SkeletonGrid-CtHKKMLj.js +1 -0
- package/client/dist/assets/SkeletonGrid-DhPYefXr.css +1 -0
- package/client/dist/assets/SynopsisText-BcpwT5iw.js +1 -0
- package/client/dist/assets/SynopsisText-CYv2W3yR.css +1 -0
- package/client/dist/assets/Trackers-Bfoiyuzy.js +2 -0
- package/client/dist/assets/Trackers-D5XBOCDp.css +1 -0
- package/client/dist/assets/Tv-Ca0fCdko.js +3 -0
- package/client/dist/assets/Tv-CuEl3Iol.css +1 -0
- package/client/dist/assets/TvInfo-Bu36xJyO.css +1 -0
- package/client/dist/assets/TvInfo-D_scgw_h.js +1 -0
- package/client/dist/assets/TvPopup-YTI7dEbJ.js +1 -0
- package/client/dist/assets/TvSearch-CxU33UOf.js +1 -0
- package/client/dist/assets/TvWatchlist-B4X1fWEH.js +1 -0
- package/client/dist/assets/VirtualKeyboard-6ZNTym4R.js +1 -0
- package/client/dist/assets/VirtualKeyboard-KIPc5ZGH.css +1 -0
- package/client/dist/assets/Watchlist-BiDEU0iV.css +1 -0
- package/client/dist/assets/Watchlist-P6YMZUdF.js +1 -0
- package/client/dist/assets/Watchlist.module-XGtVQr1d.js +1 -0
- package/client/dist/assets/hls-BOLXTnjL.js +42 -0
- package/client/dist/assets/hls-C4cG9wQw.js +1 -0
- package/client/dist/assets/index-BOKlZEHq.js +30354 -0
- package/client/dist/assets/index-CUm1meqr.css +1 -0
- package/client/dist/assets/index-IZgh1beL.js +178 -0
- package/client/dist/assets/useAnimeInfoData-DeuwDSnI.js +1 -0
- package/client/dist/assets/useAsmr-CbMVRjmV.js +1 -0
- package/client/dist/assets/useAsmrPopup-D91Q3CBg.js +1 -0
- package/client/dist/assets/useIsMobile-Dm_b6Ob3.js +1 -0
- package/client/dist/assets/useMangaLibrary-BPvFb1lU.js +1 -0
- package/client/dist/assets/useMangaPopup-BOL2mQJs.js +1 -0
- package/client/dist/assets/useMatureConsent-CT7-KfzG.js +1 -0
- package/client/dist/assets/useProviders-BsRGONMA.js +1 -0
- package/client/dist/assets/useTv-D37MfTHA.js +1 -0
- package/client/dist/assets/useTvLibrary-Cwxgy_Bg.js +1 -0
- package/client/dist/assets/vendor-core-8R2fFbyN.js +1 -0
- package/client/dist/assets/vendor-icons-CDMuNDeL.js +1 -0
- package/client/dist/assets/vendor-query-BXRECSI6.js +1 -0
- package/client/dist/assets/vendor-ui-CUDCvRS_.js +1 -0
- package/client/dist/av-sync-test.mp4 +0 -0
- package/client/dist/favicon.ico +0 -0
- package/client/dist/index.html +46 -0
- package/client/dist/logo-mochi.png +0 -0
- package/client/dist/logo-sakura.png +0 -0
- package/client/dist/logo.png +0 -0
- package/client/dist/placeholder.svg +4 -0
- package/client/dist/robots.txt +3 -0
- package/orchestrator.js +534 -0
- package/package.json +85 -0
- package/server/dist/app-auth.js +177 -0
- package/server/dist/config.js +175 -0
- package/server/dist/controllers/asmr-library.controller.js +288 -0
- package/server/dist/controllers/auth.controller.js +225 -0
- package/server/dist/controllers/data.controller.js +766 -0
- package/server/dist/controllers/insights.controller.js +198 -0
- package/server/dist/controllers/manga-library.controller.js +296 -0
- package/server/dist/controllers/music.controller.js +698 -0
- package/server/dist/controllers/proxy.controller.js +820 -0
- package/server/dist/controllers/settings.controller.js +609 -0
- package/server/dist/controllers/tv-library.controller.js +310 -0
- package/server/dist/controllers/watchlist.controller.js +1015 -0
- package/server/dist/db.js +145 -0
- package/server/dist/discord-gateway.js +391 -0
- package/server/dist/discord-rpc.js +427 -0
- package/server/dist/github-sync.js +512 -0
- package/server/dist/google.js +748 -0
- package/server/dist/lib/anilist.js +1549 -0
- package/server/dist/lib/anischedule.js +194 -0
- package/server/dist/lib/discord-roles-sync.service.js +130 -0
- package/server/dist/lib/ipc.js +24 -0
- package/server/dist/lib/kitsu.js +544 -0
- package/server/dist/lib/mal.js +934 -0
- package/server/dist/lib/migration.js +329 -0
- package/server/dist/lib/offline-db.js +401 -0
- package/server/dist/lib/temp-ids.js +3 -0
- package/server/dist/lib/tmdb.js +72 -0
- package/server/dist/lib/tracker/anilist-tracker.js +245 -0
- package/server/dist/lib/tracker/sync.service.js +496 -0
- package/server/dist/lib/ytmusic.js +125 -0
- package/server/dist/logger.js +16 -0
- package/server/dist/providers/manga/manga.types.js +7 -0
- package/server/dist/providers/provider.interface.js +1 -0
- package/server/dist/providers/remote-loader.js +574 -0
- package/server/dist/providers/remote-types.js +117 -0
- package/server/dist/providers/title-matching.js +225 -0
- package/server/dist/providers/tv.types.js +1 -0
- package/server/dist/rclone.js +120 -0
- package/server/dist/repositories/asmr.repository.js +123 -0
- package/server/dist/repositories/insights.repository.js +85 -0
- package/server/dist/repositories/library.repository.js +108 -0
- package/server/dist/repositories/mal-cache.repository.js +55 -0
- package/server/dist/repositories/manga.repository.js +138 -0
- package/server/dist/repositories/notifications.repository.js +41 -0
- package/server/dist/repositories/queue.repository.js +61 -0
- package/server/dist/repositories/settings.repository.js +12 -0
- package/server/dist/repositories/shows-meta.repository.js +42 -0
- package/server/dist/repositories/temp-show-ids.repository.js +51 -0
- package/server/dist/repositories/tv.repository.js +151 -0
- package/server/dist/repositories/watched-episodes.repository.js +52 -0
- package/server/dist/repositories/watchlist.repository.js +71 -0
- package/server/dist/request-logger.js +16 -0
- package/server/dist/routes/asmr-library.routes.js +23 -0
- package/server/dist/routes/asmr.routes.js +81 -0
- package/server/dist/routes/auth.routes.js +25 -0
- package/server/dist/routes/data.routes.js +47 -0
- package/server/dist/routes/discord-gateway.routes.js +51 -0
- package/server/dist/routes/insights.routes.js +34 -0
- package/server/dist/routes/lan-auth.routes.js +46 -0
- package/server/dist/routes/manga-library.routes.js +23 -0
- package/server/dist/routes/manga.routes.js +141 -0
- package/server/dist/routes/music.routes.js +20 -0
- package/server/dist/routes/providers.routes.js +17 -0
- package/server/dist/routes/proxy.routes.js +12 -0
- package/server/dist/routes/radio.routes.js +138 -0
- package/server/dist/routes/settings.routes.js +24 -0
- package/server/dist/routes/tracker.routes.js +164 -0
- package/server/dist/routes/translate.routes.js +88 -0
- package/server/dist/routes/tv-library.routes.js +23 -0
- package/server/dist/routes/tv.routes.js +629 -0
- package/server/dist/routes/watchlist.routes.js +341 -0
- package/server/dist/scripts/backfill-episode-duration.js +42 -0
- package/server/dist/server.js +529 -0
- package/server/dist/shipped-defaults.js +7 -0
- package/server/dist/sync-config.js +23 -0
- package/server/dist/sync-payload.js +91 -0
- package/server/dist/sync.js +1128 -0
- package/server/dist/utils/cache.utils.js +25 -0
- package/server/dist/utils/cookie.utils.js +13 -0
- package/server/dist/utils/db-utils.js +9 -0
- package/server/dist/utils/env.utils.js +77 -0
- package/server/dist/utils/http.utils.js +70 -0
- package/server/dist/utils/machine-id.js +40 -0
- package/server/dist/utils/megaplay.utils.js +41 -0
- package/server/dist/utils/request-context.js +2 -0
- package/server/dist/utils/security.utils.js +94 -0
- package/server/package.json +44 -0
|
@@ -0,0 +1,1549 @@
|
|
|
1
|
+
import logger from '../logger.js';
|
|
2
|
+
import { parseJsonBody } from '../utils/http.utils.js';
|
|
3
|
+
import { findTmdbDefaultBackdrop } from './tmdb.js';
|
|
4
|
+
import { getScheduleFromAniSchedule, getAiredEpisodesFromAniScheduleFeed, withScheduleFields, } from './anischedule.js';
|
|
5
|
+
import { kitsuSearchAnime, kitsuTrending, kitsuSeasonal, kitsuSpotlight, kitsuLatestReleases, kitsuMetaByAnilistId, kitsuMetaByMalId, kitsuEpisodes, } from './kitsu.js';
|
|
6
|
+
import { malSearchMedia, malSeasonal, malTop, malLatestReleases, malSearchAnime, malAnimeDetail, enrichEnglishFromMalDetail, EN_TITLE_CACHE_PREFIX, toAnilistSearchMedia, toAnilistDetailMedia, } from './mal.js';
|
|
7
|
+
import { malCacheStore } from '../repositories/mal-cache.repository.js';
|
|
8
|
+
import { offlineDb } from './offline-db.js';
|
|
9
|
+
const ANILIST_API = 'https://graphql.anilist.co';
|
|
10
|
+
let anilistDownUntil = 0;
|
|
11
|
+
let anilistRateLimited = false;
|
|
12
|
+
let anilistWasDownAtBoot = false;
|
|
13
|
+
const DOWN_COOLDOWN_MS = 10 * 60 * 1000;
|
|
14
|
+
const NETWORK_COOLDOWN_MS = 2 * 60 * 1000;
|
|
15
|
+
const RATE_LIMIT_FALLBACK_THRESHOLD_MS = 30 * 1000;
|
|
16
|
+
export function anilistUnavailable() {
|
|
17
|
+
if (anilistDownUntil > Date.now())
|
|
18
|
+
return true;
|
|
19
|
+
if (anilistRateLimited && anilistCooldownUntil - Date.now() > RATE_LIMIT_FALLBACK_THRESHOLD_MS) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
export function anilistIsDown() {
|
|
25
|
+
return anilistDownUntil > Date.now();
|
|
26
|
+
}
|
|
27
|
+
export function isAnilistRateLimited() {
|
|
28
|
+
return anilistRateLimited && anilistCooldownUntil > Date.now();
|
|
29
|
+
}
|
|
30
|
+
export function markAnilistDownAtBoot() {
|
|
31
|
+
anilistWasDownAtBoot = anilistUnavailable();
|
|
32
|
+
}
|
|
33
|
+
export async function checkAnilistStatus() {
|
|
34
|
+
const available = await performAnilistStatusCheck();
|
|
35
|
+
if (!available) {
|
|
36
|
+
anilistWasDownAtBoot = true;
|
|
37
|
+
}
|
|
38
|
+
return available;
|
|
39
|
+
}
|
|
40
|
+
async function performAnilistStatusCheck() {
|
|
41
|
+
try {
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
44
|
+
const response = await fetch(ANILIST_API, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
47
|
+
body: JSON.stringify({ query: 'query { __typename }' }),
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
});
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
const available = response.ok;
|
|
52
|
+
if (!available) {
|
|
53
|
+
anilistDownUntil = Date.now() + DOWN_COOLDOWN_MS;
|
|
54
|
+
anilistRateLimited = false;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
anilistDownUntil = 0;
|
|
58
|
+
anilistRateLimited = false;
|
|
59
|
+
}
|
|
60
|
+
return available;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
anilistDownUntil = Date.now() + DOWN_COOLDOWN_MS;
|
|
64
|
+
anilistRateLimited = false;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function wasAnilistDownAtBoot() {
|
|
69
|
+
return anilistWasDownAtBoot;
|
|
70
|
+
}
|
|
71
|
+
const BURST_CAPACITY = 8;
|
|
72
|
+
const DEFAULT_REFILL_INTERVAL_MS = 1500;
|
|
73
|
+
let refillIntervalMs = DEFAULT_REFILL_INTERVAL_MS;
|
|
74
|
+
let tokens = BURST_CAPACITY;
|
|
75
|
+
let lastRefill = Date.now();
|
|
76
|
+
let anilistCooldownUntil = 0;
|
|
77
|
+
function refillTokens() {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
if (now < lastRefill)
|
|
80
|
+
return;
|
|
81
|
+
const elapsed = now - lastRefill;
|
|
82
|
+
const refillAmount = Math.floor(elapsed / refillIntervalMs);
|
|
83
|
+
if (refillAmount > 0) {
|
|
84
|
+
tokens = Math.min(BURST_CAPACITY, tokens + refillAmount);
|
|
85
|
+
lastRefill += refillAmount * refillIntervalMs;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function applyRateLimitHeaders(headers) {
|
|
89
|
+
const readHeader = (name) => {
|
|
90
|
+
const raw = headers.get(name);
|
|
91
|
+
if (raw === null || raw === '')
|
|
92
|
+
return null;
|
|
93
|
+
const value = Number.parseInt(raw, 10);
|
|
94
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
95
|
+
};
|
|
96
|
+
const limit = readHeader('X-RateLimit-Limit');
|
|
97
|
+
const remaining = readHeader('X-RateLimit-Remaining');
|
|
98
|
+
if (limit !== null && limit > 0) {
|
|
99
|
+
const reserve = Math.max(1, Math.round(limit * 0.2));
|
|
100
|
+
const safeRate = Math.max(1, limit - reserve);
|
|
101
|
+
refillIntervalMs = Math.max(300, Math.min(60000, Math.round(60000 / safeRate)));
|
|
102
|
+
}
|
|
103
|
+
if (remaining !== null && remaining <= 2) {
|
|
104
|
+
const reset = readHeader('X-RateLimit-Reset');
|
|
105
|
+
if (reset !== null) {
|
|
106
|
+
const resetMs = reset * 1000;
|
|
107
|
+
if (resetMs > Date.now()) {
|
|
108
|
+
anilistCooldownUntil = Math.max(anilistCooldownUntil, resetMs);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const anilistMemoryCache = new Map();
|
|
114
|
+
const ANILIST_MEMORY_TTL = 60 * 60 * 1000;
|
|
115
|
+
const airedEpisodesCache = new Map();
|
|
116
|
+
const AIRED_EPISODES_TTL = 60 * 60 * 1000;
|
|
117
|
+
const inFlightAnilistRequests = new Map();
|
|
118
|
+
export function mediaFields() {
|
|
119
|
+
return `
|
|
120
|
+
id
|
|
121
|
+
idMal
|
|
122
|
+
title { romaji english native }
|
|
123
|
+
bannerImage
|
|
124
|
+
coverImage { extraLarge large medium color }
|
|
125
|
+
description
|
|
126
|
+
genres
|
|
127
|
+
averageScore
|
|
128
|
+
meanScore
|
|
129
|
+
format
|
|
130
|
+
status
|
|
131
|
+
episodes
|
|
132
|
+
duration
|
|
133
|
+
season
|
|
134
|
+
seasonYear
|
|
135
|
+
startDate { year month day }
|
|
136
|
+
endDate { year month day }
|
|
137
|
+
countryOfOrigin
|
|
138
|
+
isAdult
|
|
139
|
+
synonyms
|
|
140
|
+
trending
|
|
141
|
+
popularity
|
|
142
|
+
favourites
|
|
143
|
+
tags { id name rank isMediaSpoiler }
|
|
144
|
+
studios(isMain: true) { nodes { id name } }
|
|
145
|
+
nextAiringEpisode { episode timeUntilAiring airingAt }
|
|
146
|
+
trailer { id site thumbnail }
|
|
147
|
+
rankings { id rank type format allTime context }
|
|
148
|
+
siteUrl
|
|
149
|
+
`;
|
|
150
|
+
}
|
|
151
|
+
export function mediaFieldsLight() {
|
|
152
|
+
return `
|
|
153
|
+
id
|
|
154
|
+
title { romaji english native }
|
|
155
|
+
coverImage { extraLarge large }
|
|
156
|
+
status
|
|
157
|
+
averageScore
|
|
158
|
+
episodes
|
|
159
|
+
seasonYear
|
|
160
|
+
season
|
|
161
|
+
genres
|
|
162
|
+
isAdult
|
|
163
|
+
format
|
|
164
|
+
nextAiringEpisode { episode }
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
async function tryFallback(malFn, kitsuFn) {
|
|
168
|
+
for (const fn of [malFn, kitsuFn]) {
|
|
169
|
+
try {
|
|
170
|
+
const result = await fn();
|
|
171
|
+
if (result && result.length > 0)
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// ignore
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
function mapFallbackResults(fb) {
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
const results = [];
|
|
183
|
+
for (const m of fb) {
|
|
184
|
+
if (!m.id || seen.has(m.id))
|
|
185
|
+
continue;
|
|
186
|
+
seen.add(m.id);
|
|
187
|
+
results.push(fromAnilistMedia(m));
|
|
188
|
+
}
|
|
189
|
+
return results;
|
|
190
|
+
}
|
|
191
|
+
export async function getBatchedHomeData(format) {
|
|
192
|
+
const now = new Date();
|
|
193
|
+
const year = now.getFullYear();
|
|
194
|
+
const month = now.getMonth() + 1;
|
|
195
|
+
const currentSeason = month <= 3 ? 'WINTER' : month <= 6 ? 'SPRING' : month <= 9 ? 'SUMMER' : 'FALL';
|
|
196
|
+
const formatVar = format && format !== 'ALL' ? format.toUpperCase() : undefined;
|
|
197
|
+
const formatFilter = formatVar ? `format: $seasonFormat,` : '';
|
|
198
|
+
const query = `
|
|
199
|
+
query ($season: MediaSeason, $seasonYear: Int, $seasonFormat: MediaFormat) {
|
|
200
|
+
trending: Page(perPage: 20) {
|
|
201
|
+
media(sort: TRENDING_DESC, type: ANIME, isAdult: false, status: RELEASING) {
|
|
202
|
+
${mediaFieldsLight()}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
seasonal: Page(perPage: 14) {
|
|
206
|
+
media(season: $season, seasonYear: $seasonYear, type: ANIME, isAdult: false, ${formatFilter} sort: [POPULARITY_DESC, SCORE_DESC]) {
|
|
207
|
+
${mediaFieldsLight()}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
spotlight: Page(perPage: 20) {
|
|
211
|
+
media(sort: TRENDING_DESC, type: ANIME, isAdult: false) {
|
|
212
|
+
${mediaFields()}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
`;
|
|
217
|
+
const result = await anilistRequest(query, { season: currentSeason, seasonYear: year, seasonFormat: formatVar });
|
|
218
|
+
if (!result?.data) {
|
|
219
|
+
if (!anilistUnavailable())
|
|
220
|
+
return { trending: [], seasonal: [], spotlight: [] };
|
|
221
|
+
try {
|
|
222
|
+
const [trending, seasonal, spotlight] = await Promise.all([
|
|
223
|
+
tryFallback(() => malTop(malCacheStore(), 'airing', 1).then((m) => m.map(fromAnilistMedia)), () => kitsuTrending(1, 20).then((m) => m.map(fromAnilistMedia))),
|
|
224
|
+
tryFallback(() => malSeasonal(malCacheStore(), currentSeason, year, formatVar, 1, 14).then((m) => m.map(fromAnilistMedia)), () => kitsuSeasonal(currentSeason, year, formatVar, 1, 14).then((m) => m.map(fromAnilistMedia))),
|
|
225
|
+
tryFallback(() => malTop(malCacheStore(), 'airing', 1).then((m) => m.map(fromAnilistMedia)), () => kitsuSpotlight(1, 20).then((m) => m.map(fromAnilistMedia))),
|
|
226
|
+
]);
|
|
227
|
+
return { trending, seasonal, spotlight };
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return { trending: [], seasonal: [], spotlight: [] };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
trending: (result.data.trending?.media ?? []).map(fromAnilistMedia),
|
|
235
|
+
seasonal: (result.data.seasonal?.media ?? []).map(fromAnilistMedia),
|
|
236
|
+
spotlight: (result.data.spotlight?.media ?? []).map(fromAnilistMedia),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function getAnilistCacheKey(query, variables) {
|
|
240
|
+
return `${query}:${JSON.stringify(variables || {})}`;
|
|
241
|
+
}
|
|
242
|
+
function getCachedAnilist(key) {
|
|
243
|
+
const entry = anilistMemoryCache.get(key);
|
|
244
|
+
if (entry && Date.now() < entry.expiry)
|
|
245
|
+
return entry.data;
|
|
246
|
+
anilistMemoryCache.delete(key);
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
export function setCachedAnilist(key, data) {
|
|
250
|
+
anilistMemoryCache.set(key, { data, expiry: Date.now() + ANILIST_MEMORY_TTL });
|
|
251
|
+
}
|
|
252
|
+
function getCachedAiredEpisodes(key) {
|
|
253
|
+
const entry = airedEpisodesCache.get(key);
|
|
254
|
+
if (entry && Date.now() < entry.expiry)
|
|
255
|
+
return entry.data;
|
|
256
|
+
airedEpisodesCache.delete(key);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
function setCachedAiredEpisodes(key, data) {
|
|
260
|
+
airedEpisodesCache.set(key, { data, expiry: Date.now() + AIRED_EPISODES_TTL });
|
|
261
|
+
}
|
|
262
|
+
export async function waitForAnilistSlot() {
|
|
263
|
+
while (true) {
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
if (now < anilistCooldownUntil) {
|
|
266
|
+
await new Promise((resolve) => setTimeout(resolve, anilistCooldownUntil - now));
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
refillTokens();
|
|
270
|
+
if (tokens >= 1) {
|
|
271
|
+
tokens -= 1;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await new Promise((resolve) => setTimeout(resolve, refillIntervalMs));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function performAnilistRequest(query, variables, retryCount = 0) {
|
|
278
|
+
await waitForAnilistSlot();
|
|
279
|
+
try {
|
|
280
|
+
const response = await fetch(ANILIST_API, {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
283
|
+
body: JSON.stringify({ query, variables }),
|
|
284
|
+
});
|
|
285
|
+
applyRateLimitHeaders(response.headers);
|
|
286
|
+
if (response.status === 429) {
|
|
287
|
+
anilistRateLimited = true;
|
|
288
|
+
if (retryCount >= 5) {
|
|
289
|
+
return { data: null, errors: [{ message: 'AniList rate limited' }] };
|
|
290
|
+
}
|
|
291
|
+
const retryAfterHeader = Number.parseInt(response.headers.get('Retry-After') || '', 10);
|
|
292
|
+
const retryAfter = Number.isFinite(retryAfterHeader) && retryAfterHeader >= 0 ? retryAfterHeader : 5;
|
|
293
|
+
const delay = Math.min(retryAfter * 1000, 60000);
|
|
294
|
+
anilistCooldownUntil = Math.max(anilistCooldownUntil, Date.now() + delay);
|
|
295
|
+
refillIntervalMs = Math.min(60000, Math.max(refillIntervalMs, 1500) * 1.15);
|
|
296
|
+
logger.warn({ retryAfter, retryCount }, 'AniList rate limited, retrying');
|
|
297
|
+
return performAnilistRequest(query, variables, retryCount + 1);
|
|
298
|
+
}
|
|
299
|
+
if (response.status === 403 || response.status >= 500) {
|
|
300
|
+
anilistDownUntil = Date.now() + DOWN_COOLDOWN_MS;
|
|
301
|
+
anilistRateLimited = false;
|
|
302
|
+
}
|
|
303
|
+
else if (response.status >= 200 && response.status < 300) {
|
|
304
|
+
anilistDownUntil = 0;
|
|
305
|
+
anilistRateLimited = false;
|
|
306
|
+
}
|
|
307
|
+
const json = (await parseJsonBody(response));
|
|
308
|
+
if (json.errors && !json.data) {
|
|
309
|
+
logger.warn({ status: response.status, errors: json.errors }, 'AniList request failed');
|
|
310
|
+
}
|
|
311
|
+
return { data: json.data ?? null, errors: json.errors };
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
anilistDownUntil = Math.max(anilistDownUntil, Date.now() + NETWORK_COOLDOWN_MS);
|
|
315
|
+
logger.error({ err }, 'AniList request error');
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
export function anilistRequest(query, variables) {
|
|
320
|
+
if (anilistUnavailable()) {
|
|
321
|
+
return Promise.resolve({ data: null, errors: [{ message: 'AniList unavailable' }] });
|
|
322
|
+
}
|
|
323
|
+
const cacheKey = getAnilistCacheKey(query, variables);
|
|
324
|
+
const existing = inFlightAnilistRequests.get(cacheKey);
|
|
325
|
+
if (existing)
|
|
326
|
+
return existing;
|
|
327
|
+
const request = performAnilistRequest(query, variables);
|
|
328
|
+
inFlightAnilistRequests.set(cacheKey, request);
|
|
329
|
+
request.then(() => inFlightAnilistRequests.delete(cacheKey), () => inFlightAnilistRequests.delete(cacheKey));
|
|
330
|
+
return request;
|
|
331
|
+
}
|
|
332
|
+
const STATIC_GENRES = [
|
|
333
|
+
'Action',
|
|
334
|
+
'Adventure',
|
|
335
|
+
'Comedy',
|
|
336
|
+
'Drama',
|
|
337
|
+
'Fantasy',
|
|
338
|
+
'Horror',
|
|
339
|
+
'Mahou Shoujo',
|
|
340
|
+
'Mecha',
|
|
341
|
+
'Music',
|
|
342
|
+
'Mystery',
|
|
343
|
+
'Psychological',
|
|
344
|
+
'Romance',
|
|
345
|
+
'Sci-Fi',
|
|
346
|
+
'Slice of Life',
|
|
347
|
+
'Sports',
|
|
348
|
+
'Supernatural',
|
|
349
|
+
'Thriller',
|
|
350
|
+
];
|
|
351
|
+
let genreTagCache = null;
|
|
352
|
+
const GENRE_TAG_TTL_MS = 24 * 3600 * 1000;
|
|
353
|
+
export async function getGenreTagLists() {
|
|
354
|
+
if (genreTagCache && Date.now() - genreTagCache.at < GENRE_TAG_TTL_MS) {
|
|
355
|
+
return { genres: genreTagCache.genres, tags: genreTagCache.tags };
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const result = await anilistRequest(`query { GenreCollection MediaTagCollection { name isAdult } }`);
|
|
359
|
+
const genres = result?.data?.GenreCollection?.filter((g) => typeof g === 'string');
|
|
360
|
+
const tags = (result?.data?.MediaTagCollection ?? [])
|
|
361
|
+
.filter((t) => t && !t.isAdult && typeof t.name === 'string')
|
|
362
|
+
.map((t) => t.name);
|
|
363
|
+
if (genres && genres.length > 0) {
|
|
364
|
+
genreTagCache = { genres, tags, at: Date.now() };
|
|
365
|
+
return { genres, tags };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// ignore
|
|
370
|
+
}
|
|
371
|
+
return { genres: STATIC_GENRES, tags: [] };
|
|
372
|
+
}
|
|
373
|
+
function stripHtml(input) {
|
|
374
|
+
if (!input)
|
|
375
|
+
return '';
|
|
376
|
+
return input
|
|
377
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
378
|
+
.replace(/<[^>]+>/g, '')
|
|
379
|
+
.replace(/&/g, '&')
|
|
380
|
+
.replace(/"/g, '"')
|
|
381
|
+
.replace(/�?39;|'/g, "'")
|
|
382
|
+
.replace(/</g, '<')
|
|
383
|
+
.replace(/>/g, '>')
|
|
384
|
+
.trim();
|
|
385
|
+
}
|
|
386
|
+
export function fromAnilistMedia(m) {
|
|
387
|
+
const id = m.id < 0 ? `mal-${Math.abs(m.id)}` : m.id.toString();
|
|
388
|
+
const title = m.title;
|
|
389
|
+
const name = title?.romaji || title?.english || title?.native || 'Unknown';
|
|
390
|
+
const nextEpisodeAirDate = m.nextAiringEpisode?.airingAt != null
|
|
391
|
+
? new Date(m.nextAiringEpisode.airingAt * 1000).toLocaleDateString('en-US', {
|
|
392
|
+
month: 'short',
|
|
393
|
+
day: 'numeric',
|
|
394
|
+
year: 'numeric',
|
|
395
|
+
timeZone: 'UTC',
|
|
396
|
+
})
|
|
397
|
+
: undefined;
|
|
398
|
+
return {
|
|
399
|
+
_id: id,
|
|
400
|
+
id,
|
|
401
|
+
anilistId: m.id > 0 ? m.id : undefined,
|
|
402
|
+
name,
|
|
403
|
+
englishName: title?.english,
|
|
404
|
+
nativeName: title?.native,
|
|
405
|
+
names: {
|
|
406
|
+
romaji: title?.romaji,
|
|
407
|
+
english: title?.english,
|
|
408
|
+
native: title?.native,
|
|
409
|
+
synonyms: m.synonyms,
|
|
410
|
+
},
|
|
411
|
+
thumbnail: m.coverImage?.extraLarge || m.coverImage?.large || '',
|
|
412
|
+
bannerImage: m.bannerImage || undefined,
|
|
413
|
+
description: stripHtml(m.description),
|
|
414
|
+
genres: (m.genres?.slice(0, 6) || []).map((g) => ({ name: g })),
|
|
415
|
+
score: m.averageScore != null ? Number((m.averageScore / 10).toFixed(1)) : undefined,
|
|
416
|
+
type: m.format,
|
|
417
|
+
status: m.status,
|
|
418
|
+
episodeCount: m.episodes ?? undefined,
|
|
419
|
+
year: m.seasonYear ?? undefined,
|
|
420
|
+
season: m.season
|
|
421
|
+
? { season: m.season, title: m.season, year: m.seasonYear ?? undefined }
|
|
422
|
+
: undefined,
|
|
423
|
+
nextAiring: m.nextAiringEpisode
|
|
424
|
+
? {
|
|
425
|
+
episode: m.nextAiringEpisode.episode,
|
|
426
|
+
timeUntilAiring: m.nextAiringEpisode.timeUntilAiring,
|
|
427
|
+
}
|
|
428
|
+
: undefined,
|
|
429
|
+
nextEpisodeAirDate,
|
|
430
|
+
studios: m.studios?.nodes?.map((n) => ({ name: n.name })),
|
|
431
|
+
isAdult: m.isAdult,
|
|
432
|
+
tags: m.tags?.map((t) => ({ name: t.name })),
|
|
433
|
+
averageScore: m.averageScore ?? undefined,
|
|
434
|
+
country: m.countryOfOrigin ?? undefined,
|
|
435
|
+
airedStart: m.startDate
|
|
436
|
+
? {
|
|
437
|
+
year: m.startDate.year ?? undefined,
|
|
438
|
+
month: m.startDate.month ?? undefined,
|
|
439
|
+
date: m.startDate.day ?? undefined,
|
|
440
|
+
}
|
|
441
|
+
: undefined,
|
|
442
|
+
airedEnd: m.endDate
|
|
443
|
+
? {
|
|
444
|
+
year: m.endDate.year ?? undefined,
|
|
445
|
+
month: m.endDate.month ?? undefined,
|
|
446
|
+
date: m.endDate.day ?? undefined,
|
|
447
|
+
}
|
|
448
|
+
: undefined,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
export function showFromOfflineEntry(entry) {
|
|
452
|
+
const id = String(entry.anilistId);
|
|
453
|
+
const name = entry.title || 'Unknown';
|
|
454
|
+
let genres;
|
|
455
|
+
if (entry.genres) {
|
|
456
|
+
try {
|
|
457
|
+
const parsed = JSON.parse(entry.genres);
|
|
458
|
+
if (Array.isArray(parsed)) {
|
|
459
|
+
const names = parsed.filter((g) => typeof g === 'string');
|
|
460
|
+
if (names.length > 0)
|
|
461
|
+
genres = names.map((g) => ({ name: g }));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// ignore
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
_id: id,
|
|
470
|
+
id,
|
|
471
|
+
anilistId: entry.anilistId,
|
|
472
|
+
name,
|
|
473
|
+
names: { romaji: entry.title || undefined },
|
|
474
|
+
thumbnail: entry.thumbnail || '',
|
|
475
|
+
genres,
|
|
476
|
+
type: entry.type,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
export async function getLatestReleases(format = 'TV', page = 1, size = 12) {
|
|
480
|
+
if (format === 'ADULT') {
|
|
481
|
+
return getAdultLatest(page, size);
|
|
482
|
+
}
|
|
483
|
+
const now = Math.floor(Date.now() / 1000);
|
|
484
|
+
const thirtyDaysAgo = now - 30 * 86400;
|
|
485
|
+
const needed = page * size;
|
|
486
|
+
const accumulated = [];
|
|
487
|
+
const seen = new Set();
|
|
488
|
+
let anilistPage = 1;
|
|
489
|
+
let apiFailed = false;
|
|
490
|
+
while (accumulated.length < needed && anilistPage <= 10) {
|
|
491
|
+
const query = `
|
|
492
|
+
query ($page: Int, $perPage: Int, $airingAtGt: Int, $airingAtLt: Int) {
|
|
493
|
+
Page(page: $page, perPage: $perPage) {
|
|
494
|
+
pageInfo { hasNextPage }
|
|
495
|
+
airingSchedules(sort: TIME_DESC, airingAt_lesser: $airingAtLt, airingAt_greater: $airingAtGt) {
|
|
496
|
+
id episode airingAt
|
|
497
|
+
media {
|
|
498
|
+
${mediaFieldsLight()}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
`;
|
|
504
|
+
const result = await anilistRequest(query, { page: anilistPage, perPage: 50, airingAtLt: now, airingAtGt: thirtyDaysAgo });
|
|
505
|
+
if (!result?.data) {
|
|
506
|
+
apiFailed = true;
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
const schedules = result?.data?.Page?.airingSchedules;
|
|
510
|
+
if (!schedules || schedules.length === 0)
|
|
511
|
+
break;
|
|
512
|
+
for (const s of schedules) {
|
|
513
|
+
if (!s.media)
|
|
514
|
+
continue;
|
|
515
|
+
if (format !== 'ALL' && s.media.format !== format)
|
|
516
|
+
continue;
|
|
517
|
+
if (s.media.isAdult)
|
|
518
|
+
continue;
|
|
519
|
+
if (!s.media.id || seen.has(s.media.id))
|
|
520
|
+
continue;
|
|
521
|
+
seen.add(s.media.id);
|
|
522
|
+
accumulated.push(fromAnilistMedia(s.media));
|
|
523
|
+
if (accumulated.length >= needed)
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
if (!result.data?.Page?.pageInfo?.hasNextPage)
|
|
527
|
+
break;
|
|
528
|
+
anilistPage++;
|
|
529
|
+
}
|
|
530
|
+
if (apiFailed) {
|
|
531
|
+
return (await tryFallback(() => malLatestReleases(malCacheStore(), format, page, size), () => kitsuLatestReleases(format, page, size))).map(fromAnilistMedia);
|
|
532
|
+
}
|
|
533
|
+
const start = (page - 1) * size;
|
|
534
|
+
return accumulated.slice(start, start + size);
|
|
535
|
+
}
|
|
536
|
+
async function getAdultLatest(page, size) {
|
|
537
|
+
const query = `
|
|
538
|
+
query ($page: Int, $perPage: Int) {
|
|
539
|
+
Page(page: $page, perPage: $perPage) {
|
|
540
|
+
pageInfo { total hasNextPage }
|
|
541
|
+
media(sort: [START_DATE_DESC, ID_DESC], type: ANIME, isAdult: true) {
|
|
542
|
+
${mediaFieldsLight()}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
`;
|
|
547
|
+
const result = await anilistRequest(query, { page, perPage: size });
|
|
548
|
+
if (!result?.data) {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
const media = result?.data?.Page?.media;
|
|
552
|
+
if (!media)
|
|
553
|
+
return [];
|
|
554
|
+
return media.map(fromAnilistMedia);
|
|
555
|
+
}
|
|
556
|
+
export async function getSeasonal(page = 1, size = 14, format) {
|
|
557
|
+
if (format === 'ADULT') {
|
|
558
|
+
return getAdultLatest(page, size);
|
|
559
|
+
}
|
|
560
|
+
const now = new Date();
|
|
561
|
+
const year = now.getFullYear();
|
|
562
|
+
const month = now.getMonth() + 1;
|
|
563
|
+
const currentSeason = month <= 3 ? 'WINTER' : month <= 6 ? 'SPRING' : month <= 9 ? 'SUMMER' : 'FALL';
|
|
564
|
+
const query = `
|
|
565
|
+
query ($page: Int, $perPage: Int, $season: MediaSeason, $seasonYear: Int, $format: MediaFormat) {
|
|
566
|
+
Page(page: $page, perPage: $perPage) {
|
|
567
|
+
pageInfo { hasNextPage total }
|
|
568
|
+
media(season: $season, seasonYear: $seasonYear, type: ANIME, format: $format, isAdult: false, sort: [POPULARITY_DESC, SCORE_DESC]) {
|
|
569
|
+
${mediaFieldsLight()}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
`;
|
|
574
|
+
const formatVar = format && format !== 'ALL' ? format.toUpperCase() : undefined;
|
|
575
|
+
const result = await anilistRequest(query, { page, perPage: size, season: currentSeason, seasonYear: year, format: formatVar });
|
|
576
|
+
if (!result?.data) {
|
|
577
|
+
if (anilistUnavailable()) {
|
|
578
|
+
return (await tryFallback(() => malSeasonal(malCacheStore(), currentSeason, year, format, page, size), () => kitsuSeasonal(currentSeason, year, format, page, size))).map(fromAnilistMedia);
|
|
579
|
+
}
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
const media = result?.data?.Page?.media;
|
|
583
|
+
if (!media)
|
|
584
|
+
return [];
|
|
585
|
+
return media.map(fromAnilistMedia);
|
|
586
|
+
}
|
|
587
|
+
export async function getTrending(page = 1, perPage = 20, sort = 'TRENDING_DESC', status) {
|
|
588
|
+
const media = await fetchAnilistMedia(page, perPage, sort, status);
|
|
589
|
+
if (media && media.length > 0) {
|
|
590
|
+
return media.map(fromAnilistMedia);
|
|
591
|
+
}
|
|
592
|
+
if (anilistUnavailable()) {
|
|
593
|
+
return (await tryFallback(async () => {
|
|
594
|
+
if (status === 'RELEASING')
|
|
595
|
+
return malTop(malCacheStore(), 'airing', page);
|
|
596
|
+
switch (sort) {
|
|
597
|
+
case 'POPULARITY_DESC':
|
|
598
|
+
case 'FAVOURITES_DESC':
|
|
599
|
+
return malTop(malCacheStore(), 'bypopularity', page);
|
|
600
|
+
case 'SCORE_DESC':
|
|
601
|
+
return malTop(malCacheStore(), null, page);
|
|
602
|
+
default:
|
|
603
|
+
return malTop(malCacheStore(), 'airing', page);
|
|
604
|
+
}
|
|
605
|
+
}, () => kitsuTrending(page, perPage))).map(fromAnilistMedia);
|
|
606
|
+
}
|
|
607
|
+
return [];
|
|
608
|
+
}
|
|
609
|
+
async function fetchAnilistMedia(page = 1, perPage = 20, sort = 'TRENDING_DESC', status) {
|
|
610
|
+
const statusFilter = status ? `status: ${status},` : '';
|
|
611
|
+
const query = `
|
|
612
|
+
query ($page: Int, $perPage: Int) {
|
|
613
|
+
Page(page: $page, perPage: $perPage) {
|
|
614
|
+
media(sort: ${sort}, type: ANIME, isAdult: false, ${statusFilter}) {
|
|
615
|
+
${mediaFields()}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
`;
|
|
620
|
+
const result = await anilistRequest(query, {
|
|
621
|
+
page,
|
|
622
|
+
perPage,
|
|
623
|
+
});
|
|
624
|
+
if (!result?.data)
|
|
625
|
+
return null;
|
|
626
|
+
return result?.data?.Page?.media ?? [];
|
|
627
|
+
}
|
|
628
|
+
export async function getSpotlightBanners(page = 1, perPage = 20) {
|
|
629
|
+
const media = await fetchAnilistMedia(page, perPage);
|
|
630
|
+
if (media) {
|
|
631
|
+
const withBanner = [];
|
|
632
|
+
const rest = [];
|
|
633
|
+
for (const m of media) {
|
|
634
|
+
if (withBanner.length >= 6 && rest.length >= 6)
|
|
635
|
+
break;
|
|
636
|
+
const show = fromAnilistMedia(m);
|
|
637
|
+
if (show.bannerImage) {
|
|
638
|
+
if (withBanner.length < 6)
|
|
639
|
+
withBanner.push(show);
|
|
640
|
+
}
|
|
641
|
+
else if (rest.length < 6) {
|
|
642
|
+
rest.push(show);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const results = [...withBanner];
|
|
646
|
+
for (const show of rest) {
|
|
647
|
+
if (results.length >= 6)
|
|
648
|
+
break;
|
|
649
|
+
if (show.thumbnail)
|
|
650
|
+
results.push({ ...show, bannerImage: show.thumbnail });
|
|
651
|
+
}
|
|
652
|
+
return results;
|
|
653
|
+
}
|
|
654
|
+
const now = new Date();
|
|
655
|
+
const year = now.getFullYear();
|
|
656
|
+
const month = now.getMonth() + 1;
|
|
657
|
+
const currentSeason = month <= 3 ? 'WINTER' : month <= 6 ? 'SPRING' : month <= 9 ? 'SUMMER' : 'FALL';
|
|
658
|
+
const source = await tryFallback(() => malTop(malCacheStore(), 'airing', 1), () => kitsuSeasonal(currentSeason, year, 'TV', 1, 20));
|
|
659
|
+
if (!source || source.length === 0) {
|
|
660
|
+
return [];
|
|
661
|
+
}
|
|
662
|
+
const withTimeout = (p, ms = 4000) => Promise.race([
|
|
663
|
+
p,
|
|
664
|
+
new Promise((resolve) => setTimeout(() => resolve(null), ms)),
|
|
665
|
+
]);
|
|
666
|
+
const shuffled = [...source].sort(() => Math.random() - 0.5);
|
|
667
|
+
const enriched = await Promise.allSettled(shuffled.slice(0, 10).map((m) => withTimeout(findTmdbDefaultBackdrop({
|
|
668
|
+
english: m.title?.english,
|
|
669
|
+
romaji: m.title?.romaji,
|
|
670
|
+
native: m.title?.native,
|
|
671
|
+
})).then((artwork) => ({ m, artwork }))));
|
|
672
|
+
const results = [];
|
|
673
|
+
for (const r of enriched) {
|
|
674
|
+
if (results.length >= 6)
|
|
675
|
+
break;
|
|
676
|
+
if (r.status !== 'fulfilled' || !r.value?.artwork)
|
|
677
|
+
continue;
|
|
678
|
+
const show = fromAnilistMedia(r.value.m);
|
|
679
|
+
results.push({
|
|
680
|
+
...show,
|
|
681
|
+
bannerImage: r.value.artwork.backdrop,
|
|
682
|
+
description: show.description || r.value.artwork.overview || '',
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
if (results.length < 6) {
|
|
686
|
+
const seen = new Set(results.map((s) => s._id));
|
|
687
|
+
for (const m of shuffled) {
|
|
688
|
+
if (results.length >= 6)
|
|
689
|
+
break;
|
|
690
|
+
const show = fromAnilistMedia(m);
|
|
691
|
+
if (seen.has(show._id) || !show.thumbnail)
|
|
692
|
+
continue;
|
|
693
|
+
seen.add(show._id);
|
|
694
|
+
results.push({ ...show, bannerImage: show.thumbnail });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return results;
|
|
698
|
+
}
|
|
699
|
+
export function parseMalId(id) {
|
|
700
|
+
if (typeof id === 'number')
|
|
701
|
+
return id < 0 ? Math.abs(id) : null;
|
|
702
|
+
const prefixed = /^mal-(\d+)$/i.exec(id.trim());
|
|
703
|
+
if (prefixed)
|
|
704
|
+
return parseInt(prefixed[1], 10);
|
|
705
|
+
if (/^-\d+$/.test(id.trim()))
|
|
706
|
+
return Math.abs(parseInt(id, 10));
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
function hasPoster(show) {
|
|
710
|
+
return !!show?.thumbnail && show.thumbnail.trim() !== '';
|
|
711
|
+
}
|
|
712
|
+
function posterFromMedia(m) {
|
|
713
|
+
const url = m?.coverImage?.extraLarge ||
|
|
714
|
+
m?.coverImage?.large ||
|
|
715
|
+
m?.coverImage?.medium ||
|
|
716
|
+
m?.coverImage?.original ||
|
|
717
|
+
m?.coverImage?.small ||
|
|
718
|
+
m?.coverImage?.tiny;
|
|
719
|
+
return url && url.trim() !== '' ? url : null;
|
|
720
|
+
}
|
|
721
|
+
function titleWords(s) {
|
|
722
|
+
return new Set(s
|
|
723
|
+
.toLowerCase()
|
|
724
|
+
.normalize('NFD')
|
|
725
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
726
|
+
.split(/[^a-z0-9]+/)
|
|
727
|
+
.filter((w) => w.length >= 2));
|
|
728
|
+
}
|
|
729
|
+
function titleSimilarity(a, b) {
|
|
730
|
+
if (!a.trim() || !b.trim())
|
|
731
|
+
return 0;
|
|
732
|
+
const an = a.trim().toLowerCase();
|
|
733
|
+
const bn = b.trim().toLowerCase();
|
|
734
|
+
if (an === bn || an.includes(bn) || bn.includes(an))
|
|
735
|
+
return 1;
|
|
736
|
+
const wa = titleWords(a);
|
|
737
|
+
const wb = titleWords(b);
|
|
738
|
+
if (wa.size === 0 || wb.size === 0)
|
|
739
|
+
return 0;
|
|
740
|
+
const overlap = [...wa].filter((w) => wb.has(w)).length;
|
|
741
|
+
return overlap / Math.min(wa.size, wb.size);
|
|
742
|
+
}
|
|
743
|
+
function titlesMatchAny(candidate, expected) {
|
|
744
|
+
if (!candidate)
|
|
745
|
+
return false;
|
|
746
|
+
return expected.some((t) => titleSimilarity(candidate, t) >= 0.6);
|
|
747
|
+
}
|
|
748
|
+
export async function findPosterByTitle(title, expected) {
|
|
749
|
+
const clean = title?.trim();
|
|
750
|
+
if (!clean)
|
|
751
|
+
return null;
|
|
752
|
+
const expectedTitles = (expected?.titles ?? []).filter((t) => t.trim() !== '');
|
|
753
|
+
try {
|
|
754
|
+
const { entries } = await malSearchAnime(malCacheStore(), clean, 1);
|
|
755
|
+
for (const e of entries) {
|
|
756
|
+
if (!e.imageUrl || e.imageUrl.trim() === '')
|
|
757
|
+
continue;
|
|
758
|
+
if (expected?.malId && e.idMal === expected.malId)
|
|
759
|
+
return e.imageUrl;
|
|
760
|
+
if (expected && titlesMatchAny(e.title, expectedTitles))
|
|
761
|
+
return e.imageUrl;
|
|
762
|
+
if (!expected)
|
|
763
|
+
return e.imageUrl;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
// ignore, try Kitsu next
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
const kitsu = await kitsuSearchAnime({ query: clean, page: 1, perPage: 5 });
|
|
771
|
+
for (const m of kitsu) {
|
|
772
|
+
const url = posterFromMedia(m);
|
|
773
|
+
if (!url)
|
|
774
|
+
continue;
|
|
775
|
+
if (expected?.anilistId && m.id === expected.anilistId)
|
|
776
|
+
return url;
|
|
777
|
+
if (expected?.malId && m.idMal === expected.malId)
|
|
778
|
+
return url;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
// ignore
|
|
783
|
+
}
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
async function withPosterBackfill(show, cacheKey, expected) {
|
|
787
|
+
if (!show)
|
|
788
|
+
return null;
|
|
789
|
+
if (hasPoster(show)) {
|
|
790
|
+
setCachedAnilist(cacheKey, show);
|
|
791
|
+
return show;
|
|
792
|
+
}
|
|
793
|
+
if (expected && (expected.anilistId || expected.malId)) {
|
|
794
|
+
const entry = (expected.anilistId ? offlineDb.getByAnilistId(expected.anilistId) : null) ??
|
|
795
|
+
(expected.malId ? offlineDb.getByMalId(expected.malId) : null);
|
|
796
|
+
const thumb = entry?.thumbnail;
|
|
797
|
+
if (thumb && thumb.trim() !== '') {
|
|
798
|
+
show.thumbnail = thumb;
|
|
799
|
+
setCachedAnilist(cacheKey, show);
|
|
800
|
+
return show;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
const candidates = [show.names?.english, show.englishName, show.name, show.names?.romaji]
|
|
804
|
+
.filter((t) => !!t && t.trim().length >= 4)
|
|
805
|
+
.filter((t, i, a) => a.findIndex((x) => x.toLowerCase() === t.toLowerCase()) === i);
|
|
806
|
+
const exp = expected ?? { titles: [] };
|
|
807
|
+
if (exp.titles.length === 0)
|
|
808
|
+
exp.titles = [...candidates];
|
|
809
|
+
if (show.names?.native)
|
|
810
|
+
exp.titles.push(show.names.native);
|
|
811
|
+
const seen = new Set();
|
|
812
|
+
for (const title of candidates) {
|
|
813
|
+
const key = title.toLowerCase();
|
|
814
|
+
if (seen.has(key))
|
|
815
|
+
continue;
|
|
816
|
+
seen.add(key);
|
|
817
|
+
try {
|
|
818
|
+
const poster = await findPosterByTitle(title, exp);
|
|
819
|
+
if (poster) {
|
|
820
|
+
show.thumbnail = poster;
|
|
821
|
+
setCachedAnilist(cacheKey, show);
|
|
822
|
+
return show;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
// ignore
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return show;
|
|
830
|
+
}
|
|
831
|
+
async function backfillKitsuShowFromMal(show, malId) {
|
|
832
|
+
try {
|
|
833
|
+
const d = await malAnimeDetail(malCacheStore(), malId);
|
|
834
|
+
if (d.detail?.imageUrl)
|
|
835
|
+
show.thumbnail = d.detail.imageUrl;
|
|
836
|
+
try {
|
|
837
|
+
malCacheStore().put(`${EN_TITLE_CACHE_PREFIX}${malId}`, (d.detail?.titleEnglish ?? '').trim(), 7 * 24 * 3600);
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
// ignore
|
|
841
|
+
}
|
|
842
|
+
const titleEnglish = d.detail?.titleEnglish?.trim();
|
|
843
|
+
if (titleEnglish &&
|
|
844
|
+
(!show.names?.english ||
|
|
845
|
+
show.names.english.trim() === '' ||
|
|
846
|
+
show.names.english === show.names?.romaji)) {
|
|
847
|
+
show.names = { ...show.names, english: titleEnglish };
|
|
848
|
+
show.englishName = titleEnglish;
|
|
849
|
+
}
|
|
850
|
+
const titleJapanese = d.detail?.titleJapanese?.trim();
|
|
851
|
+
if (titleJapanese && !show.names?.native) {
|
|
852
|
+
show.names = { ...show.names, native: titleJapanese };
|
|
853
|
+
show.nativeName = titleJapanese;
|
|
854
|
+
}
|
|
855
|
+
if ((!show.name || show.name === 'Unknown') && (show.names?.english || show.names?.native)) {
|
|
856
|
+
show.name = show.names.english ?? show.names.native ?? show.name;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
catch {
|
|
860
|
+
// ignore
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
export async function getShowMetaById(id) {
|
|
864
|
+
const malId = parseMalId(id);
|
|
865
|
+
if (malId)
|
|
866
|
+
return getShowMetaByMalId(malId);
|
|
867
|
+
const numericId = parseInt(id);
|
|
868
|
+
if (isNaN(numericId))
|
|
869
|
+
return null;
|
|
870
|
+
if (numericId < 0)
|
|
871
|
+
return getShowMetaByMalId(Math.abs(numericId));
|
|
872
|
+
const fields = mediaFields();
|
|
873
|
+
const cacheKey = `meta:v2:${id}`;
|
|
874
|
+
const legacyCached = getCachedAnilist(`meta:${id}`);
|
|
875
|
+
const cached = getCachedAnilist(cacheKey) ?? legacyCached;
|
|
876
|
+
if (cached && hasPoster(cached)) {
|
|
877
|
+
setCachedAnilist(cacheKey, cached);
|
|
878
|
+
return cached;
|
|
879
|
+
}
|
|
880
|
+
const expected = { anilistId: numericId, titles: [] };
|
|
881
|
+
const query = `query ($id: Int) { Media(id: $id, type: ANIME) { ${fields} } }`;
|
|
882
|
+
const queryMal = `query ($id: Int) { Media(idMal: $id, type: ANIME) { ${fields} } }`;
|
|
883
|
+
const byId = await anilistRequest(query, { id: numericId });
|
|
884
|
+
if (byId?.data?.Media) {
|
|
885
|
+
const show = fromAnilistMedia(byId.data.Media);
|
|
886
|
+
return withPosterBackfill(show, cacheKey, expected);
|
|
887
|
+
}
|
|
888
|
+
const byMal = await anilistRequest(queryMal, { id: numericId });
|
|
889
|
+
if (byMal?.data?.Media) {
|
|
890
|
+
const show = fromAnilistMedia(byMal.data.Media);
|
|
891
|
+
return withPosterBackfill(show, cacheKey, expected);
|
|
892
|
+
}
|
|
893
|
+
const apiFailed = !byId?.data && !byMal?.data;
|
|
894
|
+
if (apiFailed && anilistUnavailable()) {
|
|
895
|
+
const offline = offlineDb.getByAnilistId(numericId) ?? offlineDb.getByMalId(numericId);
|
|
896
|
+
const offlineShow = offline ? showFromOfflineEntry(offline) : null;
|
|
897
|
+
if (offlineShow && hasPoster(offlineShow)) {
|
|
898
|
+
setCachedAnilist(cacheKey, offlineShow);
|
|
899
|
+
return offlineShow;
|
|
900
|
+
}
|
|
901
|
+
const fb = (await kitsuMetaByAnilistId(numericId)) ?? (await kitsuMetaByMalId(numericId));
|
|
902
|
+
if (fb) {
|
|
903
|
+
const show = fromAnilistMedia(fb);
|
|
904
|
+
if (hasPoster(show)) {
|
|
905
|
+
if (fb.idMal) {
|
|
906
|
+
await backfillKitsuShowFromMal(show, fb.idMal);
|
|
907
|
+
}
|
|
908
|
+
setCachedAnilist(cacheKey, show);
|
|
909
|
+
return show;
|
|
910
|
+
}
|
|
911
|
+
return withPosterBackfill(show, cacheKey, expected);
|
|
912
|
+
}
|
|
913
|
+
if (offlineShow) {
|
|
914
|
+
setCachedAnilist(cacheKey, offlineShow);
|
|
915
|
+
return offlineShow;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (cached && !hasPoster(cached)) {
|
|
919
|
+
return withPosterBackfill(cached, cacheKey, expected);
|
|
920
|
+
}
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
export async function getShowMetaByMalId(malId) {
|
|
924
|
+
const absMal = Math.abs(malId);
|
|
925
|
+
if (!absMal)
|
|
926
|
+
return null;
|
|
927
|
+
const cacheKey = `meta:v2:-${absMal}`;
|
|
928
|
+
const cached = getCachedAnilist(cacheKey) ?? getCachedAnilist(`meta:-${absMal}`);
|
|
929
|
+
const expected = { malId: absMal, titles: [] };
|
|
930
|
+
if (cached && cached.anilistId != null && cached.anilistId > 0 && hasPoster(cached)) {
|
|
931
|
+
setCachedAnilist(cacheKey, cached);
|
|
932
|
+
return cached;
|
|
933
|
+
}
|
|
934
|
+
const anilistUp = !anilistUnavailable();
|
|
935
|
+
if (cached && !anilistUp && hasPoster(cached)) {
|
|
936
|
+
setCachedAnilist(cacheKey, cached);
|
|
937
|
+
return cached;
|
|
938
|
+
}
|
|
939
|
+
if (anilistUp) {
|
|
940
|
+
try {
|
|
941
|
+
const byMal = await anilistRequest(`query ($id: Int) { Media(idMal: $id, type: ANIME) { ${mediaFields()} } }`, { id: absMal });
|
|
942
|
+
if (byMal?.data?.Media) {
|
|
943
|
+
const show = fromAnilistMedia(byMal.data.Media);
|
|
944
|
+
return withPosterBackfill(show, cacheKey, expected);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
// ignore
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const offline = anilistUp ? null : offlineDb.getByMalId(absMal);
|
|
952
|
+
const offlineShow = offline ? showFromOfflineEntry(offline) : null;
|
|
953
|
+
if (offlineShow && hasPoster(offlineShow)) {
|
|
954
|
+
setCachedAnilist(cacheKey, offlineShow);
|
|
955
|
+
return offlineShow;
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
const fb = await kitsuMetaByMalId(absMal);
|
|
959
|
+
if (fb) {
|
|
960
|
+
const show = fromAnilistMedia(fb);
|
|
961
|
+
await backfillKitsuShowFromMal(show, absMal);
|
|
962
|
+
if (hasPoster(show)) {
|
|
963
|
+
setCachedAnilist(cacheKey, show);
|
|
964
|
+
return show;
|
|
965
|
+
}
|
|
966
|
+
return withPosterBackfill(show, cacheKey, expected);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
catch {
|
|
970
|
+
// ignore
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
const d = await malAnimeDetail(malCacheStore(), absMal);
|
|
974
|
+
if (d.detail) {
|
|
975
|
+
const show = fromAnilistMedia(toAnilistDetailMedia(d.detail));
|
|
976
|
+
return withPosterBackfill(show, cacheKey, expected);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
catch {
|
|
980
|
+
// ignore
|
|
981
|
+
}
|
|
982
|
+
if (cached && !hasPoster(cached)) {
|
|
983
|
+
return withPosterBackfill(cached, cacheKey, expected);
|
|
984
|
+
}
|
|
985
|
+
if (offlineShow) {
|
|
986
|
+
setCachedAnilist(cacheKey, offlineShow);
|
|
987
|
+
return offlineShow;
|
|
988
|
+
}
|
|
989
|
+
logger.warn({ malId: absMal }, 'MAL id unresolvable on all metadata providers');
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
export async function batchGetShowStatuses(ids) {
|
|
993
|
+
const result = new Map();
|
|
994
|
+
if (ids.length === 0)
|
|
995
|
+
return result;
|
|
996
|
+
if (!isAnilistRateLimited()) {
|
|
997
|
+
const BATCH = 10;
|
|
998
|
+
for (let i = 0; i < ids.length; i += BATCH) {
|
|
999
|
+
const batch = ids.slice(i, i + BATCH);
|
|
1000
|
+
const aliases = batch
|
|
1001
|
+
.map((id, idx) => `a${idx}: Media(id: $id${idx}, type: ANIME) { id status }`)
|
|
1002
|
+
.join('\n');
|
|
1003
|
+
const variables = {};
|
|
1004
|
+
batch.forEach((id, idx) => {
|
|
1005
|
+
variables[`id${idx}`] = id;
|
|
1006
|
+
});
|
|
1007
|
+
try {
|
|
1008
|
+
const resp = await anilistRequest(`query (${batch.map((_, idx) => `$id${idx}: Int`).join(', ')}) { ${aliases} }`, variables);
|
|
1009
|
+
for (let j = 0; j < batch.length; j++) {
|
|
1010
|
+
const alias = `a${j}`;
|
|
1011
|
+
const media = resp?.data?.[alias];
|
|
1012
|
+
if (media?.status)
|
|
1013
|
+
result.set(media.id, media.status);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
catch {
|
|
1017
|
+
break;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (result.size === ids.length)
|
|
1021
|
+
return result;
|
|
1022
|
+
}
|
|
1023
|
+
const missing = ids.filter((id) => !result.has(id));
|
|
1024
|
+
if (missing.length > 0) {
|
|
1025
|
+
try {
|
|
1026
|
+
const { kitsuBatchGetStatuses } = await import('./kitsu.js');
|
|
1027
|
+
const kitsuStatuses = await kitsuBatchGetStatuses(missing);
|
|
1028
|
+
for (const [id, status] of kitsuStatuses) {
|
|
1029
|
+
result.set(id, status);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
// Kitsu fallback failed
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return result;
|
|
1037
|
+
}
|
|
1038
|
+
export async function getAnilistEpisodes(id) {
|
|
1039
|
+
const malId = parseMalId(id);
|
|
1040
|
+
if (malId)
|
|
1041
|
+
return getEpisodesByMalId(malId);
|
|
1042
|
+
const numericId = parseInt(id);
|
|
1043
|
+
if (isNaN(numericId))
|
|
1044
|
+
return [];
|
|
1045
|
+
if (numericId < 0)
|
|
1046
|
+
return getEpisodesByMalId(Math.abs(numericId));
|
|
1047
|
+
const cacheKey = `eps:${id}`;
|
|
1048
|
+
const cached = getCachedAnilist(cacheKey);
|
|
1049
|
+
if (cached)
|
|
1050
|
+
return cached;
|
|
1051
|
+
const subquery = `id episodes status nextAiringEpisode { episode airingAt } airingSchedule(perPage: 100) { nodes { episode airingAt } }`;
|
|
1052
|
+
const query = `query ($id: Int) { Media(id: $id, type: ANIME) { ${subquery} } }`;
|
|
1053
|
+
const queryMal = `query ($id: Int) { Media(idMal: $id, type: ANIME) { ${subquery} } }`;
|
|
1054
|
+
const byId = await anilistRequest(query, { id: numericId });
|
|
1055
|
+
const media = byId?.data?.Media;
|
|
1056
|
+
if (!media) {
|
|
1057
|
+
const byMal = await anilistRequest(queryMal, { id: numericId });
|
|
1058
|
+
if (!byMal?.data) {
|
|
1059
|
+
if (anilistUnavailable()) {
|
|
1060
|
+
const episodes = await kitsuEpisodes(numericId, numericId);
|
|
1061
|
+
if (episodes.length > 0) {
|
|
1062
|
+
setCachedAnilist(cacheKey, episodes);
|
|
1063
|
+
return episodes;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
return [];
|
|
1067
|
+
}
|
|
1068
|
+
if (!byMal?.data?.Media)
|
|
1069
|
+
return [];
|
|
1070
|
+
const episodes = extractEpisodes(byMal.data.Media);
|
|
1071
|
+
setCachedAnilist(cacheKey, episodes);
|
|
1072
|
+
return episodes;
|
|
1073
|
+
}
|
|
1074
|
+
const episodes = extractEpisodes(media);
|
|
1075
|
+
setCachedAnilist(cacheKey, episodes);
|
|
1076
|
+
return episodes;
|
|
1077
|
+
}
|
|
1078
|
+
async function getEpisodesByMalId(malId) {
|
|
1079
|
+
const cacheKey = `eps:-${malId}`;
|
|
1080
|
+
const cached = getCachedAnilist(cacheKey);
|
|
1081
|
+
if (cached)
|
|
1082
|
+
return cached;
|
|
1083
|
+
try {
|
|
1084
|
+
const episodes = await kitsuEpisodes(0, malId);
|
|
1085
|
+
if (episodes.length > 0) {
|
|
1086
|
+
setCachedAnilist(cacheKey, episodes);
|
|
1087
|
+
return episodes;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
catch {
|
|
1091
|
+
// ignore
|
|
1092
|
+
}
|
|
1093
|
+
try {
|
|
1094
|
+
const d = await malAnimeDetail(malCacheStore(), malId);
|
|
1095
|
+
const total = d.detail?.episodes;
|
|
1096
|
+
if (total && total > 0 && total <= 2000) {
|
|
1097
|
+
const episodes = Array.from({ length: total }, (_, i) => (i + 1).toString());
|
|
1098
|
+
setCachedAnilist(cacheKey, episodes);
|
|
1099
|
+
return episodes;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
catch {
|
|
1103
|
+
// ignore
|
|
1104
|
+
}
|
|
1105
|
+
return [];
|
|
1106
|
+
}
|
|
1107
|
+
function extractEpisodes(result) {
|
|
1108
|
+
const { status, episodes: total, nextAiringEpisode, airingSchedule } = result;
|
|
1109
|
+
if (status === 'FINISHED' && total) {
|
|
1110
|
+
return Array.from({ length: total }, (_, i) => (i + 1).toString());
|
|
1111
|
+
}
|
|
1112
|
+
if (status === 'RELEASING' && nextAiringEpisode?.episode) {
|
|
1113
|
+
const airedCount = nextAiringEpisode.episode - 1;
|
|
1114
|
+
if (airedCount > 0) {
|
|
1115
|
+
return Array.from({ length: airedCount }, (_, i) => (i + 1).toString());
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (airingSchedule?.nodes) {
|
|
1119
|
+
const now = Date.now() / 1000;
|
|
1120
|
+
const aired = airingSchedule.nodes.filter((n) => n.airingAt < now);
|
|
1121
|
+
if (aired.length > 0) {
|
|
1122
|
+
const max = Math.max(...aired.map((n) => n.episode));
|
|
1123
|
+
return Array.from({ length: max }, (_, i) => (i + 1).toString());
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if (total) {
|
|
1127
|
+
return Array.from({ length: total }, (_, i) => (i + 1).toString());
|
|
1128
|
+
}
|
|
1129
|
+
return [];
|
|
1130
|
+
}
|
|
1131
|
+
export async function searchAnilistByTitle(title) {
|
|
1132
|
+
const query = `
|
|
1133
|
+
query ($search: String) {
|
|
1134
|
+
Page(page: 1, perPage: 5) {
|
|
1135
|
+
media(search: $search, type: ANIME) {
|
|
1136
|
+
id
|
|
1137
|
+
title { romaji english native }
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
`;
|
|
1142
|
+
const normalizedTitle = title.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
1143
|
+
const words = normalizedTitle.split(/\s+/).filter((word) => word.length >= 3);
|
|
1144
|
+
const searchTerms = [
|
|
1145
|
+
title,
|
|
1146
|
+
normalizedTitle,
|
|
1147
|
+
normalizedTitle.replace(/:/g, ''),
|
|
1148
|
+
words.slice(0, 5).join(' '),
|
|
1149
|
+
words.slice(0, 4).join(' '),
|
|
1150
|
+
words.slice(0, 3).join(' '),
|
|
1151
|
+
words.slice(0, 2).join(' ').replace(/:/g, ''),
|
|
1152
|
+
normalizedTitle.replace(/\s*\d+(?:st|nd|rd|th)?\s*Season\s*\d*/gi, '').trim(),
|
|
1153
|
+
].filter((t, i, a) => t && a.indexOf(t) === i);
|
|
1154
|
+
let media;
|
|
1155
|
+
let anyFailed = false;
|
|
1156
|
+
for (const searchTerm of searchTerms) {
|
|
1157
|
+
const result = await anilistRequest(query, { search: searchTerm });
|
|
1158
|
+
if (!result?.data) {
|
|
1159
|
+
anyFailed = true;
|
|
1160
|
+
break;
|
|
1161
|
+
}
|
|
1162
|
+
media = result?.data?.Page?.media;
|
|
1163
|
+
if (media?.length)
|
|
1164
|
+
break;
|
|
1165
|
+
}
|
|
1166
|
+
if (!media || media.length === 0) {
|
|
1167
|
+
if (anyFailed && anilistUnavailable()) {
|
|
1168
|
+
const fb = await kitsuSearchAnime({ query: title, page: 1, perPage: 5 });
|
|
1169
|
+
if (fb.length > 0) {
|
|
1170
|
+
const best = fb[0];
|
|
1171
|
+
return { id: best.id, title: best.title ?? {} };
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
const lowerTitle = title.toLowerCase();
|
|
1177
|
+
const exactMatch = media.find((m) => m.title?.romaji?.toLowerCase() === lowerTitle ||
|
|
1178
|
+
m.title?.english?.toLowerCase() === lowerTitle ||
|
|
1179
|
+
m.title?.native === title);
|
|
1180
|
+
if (exactMatch) {
|
|
1181
|
+
return { id: exactMatch.id, title: exactMatch.title };
|
|
1182
|
+
}
|
|
1183
|
+
const inputWords = new Set(normalizedTitle
|
|
1184
|
+
.toLowerCase()
|
|
1185
|
+
.split(/\s+/)
|
|
1186
|
+
.filter((w) => w.length >= 2));
|
|
1187
|
+
let best = media[0];
|
|
1188
|
+
let bestScore = 0;
|
|
1189
|
+
for (const m of media) {
|
|
1190
|
+
const text = (m.title?.romaji || m.title?.english || '').toLowerCase();
|
|
1191
|
+
const overlap = text.split(/\s+/).filter((w) => inputWords.has(w)).length;
|
|
1192
|
+
if (overlap > bestScore) {
|
|
1193
|
+
bestScore = overlap;
|
|
1194
|
+
best = m;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return { id: best.id, title: best.title };
|
|
1198
|
+
}
|
|
1199
|
+
export async function getAiredEpisodesForShows(ids, startDate, endDate) {
|
|
1200
|
+
const dayStart = Math.floor(startDate.getTime() / 1000);
|
|
1201
|
+
const dayEnd = Math.floor(endDate.getTime() / 1000);
|
|
1202
|
+
const cacheKey = `aired:${dayStart}:${dayEnd}:${Array.from(ids)
|
|
1203
|
+
.sort((a, b) => a - b)
|
|
1204
|
+
.join(',')}`;
|
|
1205
|
+
const cached = getCachedAiredEpisodes(cacheKey);
|
|
1206
|
+
if (cached)
|
|
1207
|
+
return cached;
|
|
1208
|
+
try {
|
|
1209
|
+
const results = await getAiredEpisodesFromAniScheduleFeed(ids, dayStart, dayEnd);
|
|
1210
|
+
setCachedAiredEpisodes(cacheKey, results);
|
|
1211
|
+
return results;
|
|
1212
|
+
}
|
|
1213
|
+
catch (e) {
|
|
1214
|
+
logger.warn({ err: e }, 'AniSchedule episode feed failed, falling back to AniList aired episodes');
|
|
1215
|
+
}
|
|
1216
|
+
if (isAnilistRateLimited()) {
|
|
1217
|
+
return [];
|
|
1218
|
+
}
|
|
1219
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1220
|
+
const results = [];
|
|
1221
|
+
const seen = new Set();
|
|
1222
|
+
const BATCH = 5;
|
|
1223
|
+
for (let i = 0; i < ids.length; i += BATCH) {
|
|
1224
|
+
const batch = ids.slice(i, i + BATCH);
|
|
1225
|
+
const aliases = batch
|
|
1226
|
+
.map((id, idx) => `a${idx}: Media(id: $id${idx}, type: ANIME) { id airingSchedule(perPage: 100) { nodes { episode airingAt } } }`)
|
|
1227
|
+
.join('\n');
|
|
1228
|
+
const variables = {};
|
|
1229
|
+
batch.forEach((id, idx) => {
|
|
1230
|
+
variables[`id${idx}`] = id;
|
|
1231
|
+
});
|
|
1232
|
+
const result = await anilistRequest(`query (${batch.map((_, idx) => `$id${idx}: Int`).join(', ')}) { ${aliases} }`, variables);
|
|
1233
|
+
for (let j = 0; j < batch.length; j++) {
|
|
1234
|
+
const alias = `a${j}`;
|
|
1235
|
+
const media = result?.data?.[alias];
|
|
1236
|
+
if (!media?.airingSchedule?.nodes)
|
|
1237
|
+
continue;
|
|
1238
|
+
for (const node of media.airingSchedule.nodes) {
|
|
1239
|
+
if (node.airingAt >= dayStart && node.airingAt <= dayEnd && node.airingAt <= now) {
|
|
1240
|
+
const key = `${media.id}:${node.episode}`;
|
|
1241
|
+
if (!seen.has(key)) {
|
|
1242
|
+
seen.add(key);
|
|
1243
|
+
results.push({ mediaId: media.id, episode: node.episode, airingAt: node.airingAt });
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
setCachedAiredEpisodes(cacheKey, results);
|
|
1250
|
+
return results;
|
|
1251
|
+
}
|
|
1252
|
+
export async function getAiredEpisodesForShow(id, startDate, endDate) {
|
|
1253
|
+
const dayStart = Math.floor(startDate.getTime() / 1000);
|
|
1254
|
+
const dayEnd = Math.floor(endDate.getTime() / 1000);
|
|
1255
|
+
const query = `
|
|
1256
|
+
query ($id: Int, $dayStart: Int, $dayEnd: Int) {
|
|
1257
|
+
Media(id: $id, type: ANIME) {
|
|
1258
|
+
id
|
|
1259
|
+
airingSchedule(airingAt_greater: $dayStart, airingAt_lesser: $dayEnd) {
|
|
1260
|
+
episode
|
|
1261
|
+
airingAt
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
`;
|
|
1266
|
+
const result = await anilistRequest(query, { id, dayStart, dayEnd });
|
|
1267
|
+
const schedules = result?.data?.Media?.airingSchedule;
|
|
1268
|
+
if (!schedules || schedules.length === 0)
|
|
1269
|
+
return [];
|
|
1270
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1271
|
+
return schedules.filter((s) => s.airingAt <= now).map((s) => s.episode);
|
|
1272
|
+
}
|
|
1273
|
+
export async function getSchedule(date, format, adult = false) {
|
|
1274
|
+
if (format === 'ADULT') {
|
|
1275
|
+
adult = true;
|
|
1276
|
+
format = undefined;
|
|
1277
|
+
}
|
|
1278
|
+
try {
|
|
1279
|
+
const result = await getScheduleFromAniSchedule(date, format, adult);
|
|
1280
|
+
const shows = result.entries
|
|
1281
|
+
.filter((e) => e.show)
|
|
1282
|
+
.map((e) => withScheduleFields(e.show, e.episode, e.airingAt));
|
|
1283
|
+
if (result.missingMeta.length > 0) {
|
|
1284
|
+
for (const entry of result.missingMeta) {
|
|
1285
|
+
try {
|
|
1286
|
+
const meta = await getShowMetaById(String(entry.id));
|
|
1287
|
+
if (!meta)
|
|
1288
|
+
continue;
|
|
1289
|
+
if (adult ? !meta.isAdult : meta.isAdult)
|
|
1290
|
+
continue;
|
|
1291
|
+
shows.push(withScheduleFields(meta, entry.episode, entry.airingAt));
|
|
1292
|
+
}
|
|
1293
|
+
catch {
|
|
1294
|
+
// keep going without this show
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return shows;
|
|
1299
|
+
}
|
|
1300
|
+
catch (e) {
|
|
1301
|
+
logger.warn({ err: e }, 'AniSchedule schedule failed, falling back to AniList');
|
|
1302
|
+
if (isAnilistRateLimited())
|
|
1303
|
+
return [];
|
|
1304
|
+
return getScheduleFromAnilist(date, format, adult);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
async function getScheduleFromAnilist(date, format, adult = false) {
|
|
1308
|
+
const dayStart = Math.floor(date.getTime() / 1000);
|
|
1309
|
+
const dayEnd = dayStart + 86400;
|
|
1310
|
+
const query = `
|
|
1311
|
+
query ($dayStart: Int, $dayEnd: Int) {
|
|
1312
|
+
Page(perPage: 50) {
|
|
1313
|
+
airingSchedules(airingAt_greater: $dayStart, airingAt_lesser: $dayEnd) {
|
|
1314
|
+
episode
|
|
1315
|
+
airingAt
|
|
1316
|
+
media {
|
|
1317
|
+
${mediaFields()}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
`;
|
|
1323
|
+
const result = await anilistRequest(query, { dayStart, dayEnd });
|
|
1324
|
+
const schedules = result?.data?.Page?.airingSchedules;
|
|
1325
|
+
if (!schedules || schedules.length === 0)
|
|
1326
|
+
return [];
|
|
1327
|
+
const seen = new Set();
|
|
1328
|
+
const results = [];
|
|
1329
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1330
|
+
for (const entry of schedules) {
|
|
1331
|
+
const media = entry.media;
|
|
1332
|
+
if (!media)
|
|
1333
|
+
continue;
|
|
1334
|
+
if (format && format !== 'ALL' && media.format !== format)
|
|
1335
|
+
continue;
|
|
1336
|
+
const isAdultShow = !!media.isAdult;
|
|
1337
|
+
if (adult ? !isAdultShow : isAdultShow)
|
|
1338
|
+
continue;
|
|
1339
|
+
const key = media.idMal ?? media.id;
|
|
1340
|
+
if (seen.has(key))
|
|
1341
|
+
continue;
|
|
1342
|
+
seen.add(key);
|
|
1343
|
+
const show = fromAnilistMedia(media);
|
|
1344
|
+
show.episodeNumber = entry.episode;
|
|
1345
|
+
show.aired = entry.airingAt <= now;
|
|
1346
|
+
show.nextAiring = {
|
|
1347
|
+
episode: entry.episode,
|
|
1348
|
+
timeUntilAiring: entry.airingAt - now,
|
|
1349
|
+
};
|
|
1350
|
+
show.nextEpisodeAirDate = new Date(entry.airingAt * 1000).toLocaleDateString('en-US', {
|
|
1351
|
+
month: 'short',
|
|
1352
|
+
day: 'numeric',
|
|
1353
|
+
year: 'numeric',
|
|
1354
|
+
timeZone: 'UTC',
|
|
1355
|
+
});
|
|
1356
|
+
show.airTime = new Date(entry.airingAt * 1000).toLocaleTimeString('en-US', {
|
|
1357
|
+
hour: '2-digit',
|
|
1358
|
+
minute: '2-digit',
|
|
1359
|
+
timeZone: 'UTC',
|
|
1360
|
+
});
|
|
1361
|
+
results.push(show);
|
|
1362
|
+
}
|
|
1363
|
+
return results;
|
|
1364
|
+
}
|
|
1365
|
+
function splitList(value) {
|
|
1366
|
+
const arr = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
|
|
1367
|
+
return [...new Set(arr.map((v) => v.trim()).filter(Boolean))];
|
|
1368
|
+
}
|
|
1369
|
+
export async function searchAnilist(options = {}) {
|
|
1370
|
+
const { query, page = 1, perPage = 14, format, status, season, seasonYear, countryOfOrigin, genre, genre_in, tag, tag_in, genre_not_in, tag_not_in, averageScore_greater, episodes_greater, isAdult, sort, } = options;
|
|
1371
|
+
const searchVars = {
|
|
1372
|
+
page,
|
|
1373
|
+
perPage,
|
|
1374
|
+
};
|
|
1375
|
+
if (query)
|
|
1376
|
+
searchVars.search = query;
|
|
1377
|
+
if (format && format !== 'ALL') {
|
|
1378
|
+
if (format === 'ADULT') {
|
|
1379
|
+
searchVars.isAdult = true;
|
|
1380
|
+
}
|
|
1381
|
+
else {
|
|
1382
|
+
searchVars.format = format.toUpperCase();
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
if (status)
|
|
1386
|
+
searchVars.status = status;
|
|
1387
|
+
if (season && season !== 'ALL')
|
|
1388
|
+
searchVars.season = season.toUpperCase();
|
|
1389
|
+
if (seasonYear)
|
|
1390
|
+
searchVars.seasonYear = seasonYear;
|
|
1391
|
+
if (countryOfOrigin && countryOfOrigin !== 'ALL')
|
|
1392
|
+
searchVars.countryOfOrigin = countryOfOrigin;
|
|
1393
|
+
const genreList = [...splitList(genre), ...(genre_in ?? []).map((g) => g.trim()).filter(Boolean)];
|
|
1394
|
+
const uniqueGenres = [...new Set(genreList)];
|
|
1395
|
+
if (uniqueGenres.length === 1)
|
|
1396
|
+
searchVars.genre = uniqueGenres[0];
|
|
1397
|
+
else if (uniqueGenres.length > 1)
|
|
1398
|
+
searchVars.genre_in = uniqueGenres;
|
|
1399
|
+
const tagList = [...splitList(tag), ...(tag_in ?? []).map((t) => t.trim()).filter(Boolean)];
|
|
1400
|
+
const uniqueTags = [...new Set(tagList)];
|
|
1401
|
+
if (uniqueTags.length === 1)
|
|
1402
|
+
searchVars.tag = uniqueTags[0];
|
|
1403
|
+
else if (uniqueTags.length > 1)
|
|
1404
|
+
searchVars.tag_in = uniqueTags;
|
|
1405
|
+
if (genre_not_in && genre_not_in.length > 0)
|
|
1406
|
+
searchVars.genre_not_in = genre_not_in;
|
|
1407
|
+
if (tag_not_in && tag_not_in.length > 0)
|
|
1408
|
+
searchVars.tag_not_in = tag_not_in;
|
|
1409
|
+
if (averageScore_greater)
|
|
1410
|
+
searchVars.averageScore_greater = averageScore_greater;
|
|
1411
|
+
if (episodes_greater)
|
|
1412
|
+
searchVars.episodes_greater = episodes_greater;
|
|
1413
|
+
if (isAdult !== undefined)
|
|
1414
|
+
searchVars.isAdult = isAdult;
|
|
1415
|
+
if (sort)
|
|
1416
|
+
searchVars.sort = [sort];
|
|
1417
|
+
const queryStr = `
|
|
1418
|
+
query ($page: Int, $perPage: Int, $search: String, $format: MediaFormat, $status: MediaStatus, $season: MediaSeason, $seasonYear: Int, $countryOfOrigin: CountryCode, $genre: String, $genre_in: [String], $tag: String, $tag_in: [String], $genre_not_in: [String], $tag_not_in: [String], $averageScore_greater: Int, $episodes_greater: Int, $isAdult: Boolean, $sort: [MediaSort]) {
|
|
1419
|
+
Page(page: $page, perPage: $perPage) {
|
|
1420
|
+
pageInfo { hasNextPage total }
|
|
1421
|
+
media(
|
|
1422
|
+
search: $search,
|
|
1423
|
+
type: ANIME,
|
|
1424
|
+
format: $format,
|
|
1425
|
+
status: $status,
|
|
1426
|
+
season: $season,
|
|
1427
|
+
seasonYear: $seasonYear,
|
|
1428
|
+
countryOfOrigin: $countryOfOrigin,
|
|
1429
|
+
genre: $genre,
|
|
1430
|
+
genre_in: $genre_in,
|
|
1431
|
+
tag: $tag,
|
|
1432
|
+
tag_in: $tag_in,
|
|
1433
|
+
genre_not_in: $genre_not_in,
|
|
1434
|
+
tag_not_in: $tag_not_in,
|
|
1435
|
+
averageScore_greater: $averageScore_greater,
|
|
1436
|
+
episodes_greater: $episodes_greater,
|
|
1437
|
+
isAdult: $isAdult,
|
|
1438
|
+
sort: $sort
|
|
1439
|
+
) {
|
|
1440
|
+
${mediaFieldsLight()}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
`;
|
|
1445
|
+
const result = await anilistRequest(queryStr, searchVars);
|
|
1446
|
+
if (!result?.data) {
|
|
1447
|
+
if (anilistUnavailable()) {
|
|
1448
|
+
const malSearch = async () => {
|
|
1449
|
+
const store = malCacheStore();
|
|
1450
|
+
let fb = await malSearchMedia(store, {
|
|
1451
|
+
query,
|
|
1452
|
+
page,
|
|
1453
|
+
perPage,
|
|
1454
|
+
format: format && format !== 'ALL' && format !== 'ADULT' ? format : undefined,
|
|
1455
|
+
status,
|
|
1456
|
+
season,
|
|
1457
|
+
seasonYear,
|
|
1458
|
+
genre,
|
|
1459
|
+
genre_in,
|
|
1460
|
+
genre_not_in,
|
|
1461
|
+
averageScore_greater,
|
|
1462
|
+
episodes_greater,
|
|
1463
|
+
isAdult: format === 'ADULT' ? true : isAdult,
|
|
1464
|
+
sort,
|
|
1465
|
+
});
|
|
1466
|
+
if (fb.length > 0) {
|
|
1467
|
+
try {
|
|
1468
|
+
const hentai = await malSearchMedia(store, {
|
|
1469
|
+
query,
|
|
1470
|
+
page,
|
|
1471
|
+
perPage,
|
|
1472
|
+
format: format && format !== 'ALL' && format !== 'ADULT' ? format : undefined,
|
|
1473
|
+
status,
|
|
1474
|
+
season,
|
|
1475
|
+
seasonYear,
|
|
1476
|
+
genre,
|
|
1477
|
+
genre_in,
|
|
1478
|
+
averageScore_greater,
|
|
1479
|
+
episodes_greater,
|
|
1480
|
+
sort,
|
|
1481
|
+
isAdult: true,
|
|
1482
|
+
}, false);
|
|
1483
|
+
if (hentai.length > 0) {
|
|
1484
|
+
const hentaiIds = new Set(hentai.map((m) => m.id));
|
|
1485
|
+
fb = fb.filter((m) => !hentaiIds.has(m.id));
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
catch {
|
|
1489
|
+
// ignore
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
return fb;
|
|
1493
|
+
};
|
|
1494
|
+
const fb = await tryFallback(malSearch, () => kitsuSearchAnime({
|
|
1495
|
+
query,
|
|
1496
|
+
page,
|
|
1497
|
+
perPage,
|
|
1498
|
+
format: format && format !== 'ALL' && format !== 'ADULT' ? format : undefined,
|
|
1499
|
+
status,
|
|
1500
|
+
season,
|
|
1501
|
+
seasonYear,
|
|
1502
|
+
genre,
|
|
1503
|
+
genre_in,
|
|
1504
|
+
genre_not_in,
|
|
1505
|
+
averageScore_greater,
|
|
1506
|
+
episodes_greater,
|
|
1507
|
+
isAdult: format === 'ADULT' ? true : isAdult,
|
|
1508
|
+
sort,
|
|
1509
|
+
}));
|
|
1510
|
+
try {
|
|
1511
|
+
await enrichEnglishFromMalDetail(malCacheStore(), fb);
|
|
1512
|
+
}
|
|
1513
|
+
catch {
|
|
1514
|
+
// ignore
|
|
1515
|
+
}
|
|
1516
|
+
let results = mapFallbackResults(fb);
|
|
1517
|
+
if (results.length === 0 && query?.trim()) {
|
|
1518
|
+
try {
|
|
1519
|
+
const mal = await malSearchAnime(malCacheStore(), query, page);
|
|
1520
|
+
const media = mal.entries.map((e) => toAnilistSearchMedia(e));
|
|
1521
|
+
try {
|
|
1522
|
+
await enrichEnglishFromMalDetail(malCacheStore(), media);
|
|
1523
|
+
}
|
|
1524
|
+
catch {
|
|
1525
|
+
// ignore
|
|
1526
|
+
}
|
|
1527
|
+
results = media.map((m) => fromAnilistMedia(m));
|
|
1528
|
+
}
|
|
1529
|
+
catch {
|
|
1530
|
+
// ignore
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return results;
|
|
1534
|
+
}
|
|
1535
|
+
return [];
|
|
1536
|
+
}
|
|
1537
|
+
const media = result?.data?.Page?.media;
|
|
1538
|
+
if (!media || media.length === 0)
|
|
1539
|
+
return [];
|
|
1540
|
+
const seen = new Set();
|
|
1541
|
+
const results = [];
|
|
1542
|
+
for (const m of media) {
|
|
1543
|
+
if (!m.id || seen.has(m.id))
|
|
1544
|
+
continue;
|
|
1545
|
+
seen.add(m.id);
|
|
1546
|
+
results.push(fromAnilistMedia(m));
|
|
1547
|
+
}
|
|
1548
|
+
return results;
|
|
1549
|
+
}
|