@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.
Files changed (190) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -0
  3. package/client/dist/assets/AnimeCard-B5A20jAI.js +1 -0
  4. package/client/dist/assets/AnimeInfo-0huPAq0O.js +1 -0
  5. package/client/dist/assets/AnimeInfo-BFWtHlPC.css +1 -0
  6. package/client/dist/assets/AnimeInfoPage-Dqr5QH6R.js +2 -0
  7. package/client/dist/assets/AnimeMetaDetails-BPvv5xIl.js +1 -0
  8. package/client/dist/assets/AnimeMetaDetails-BwNQnRCd.css +1 -0
  9. package/client/dist/assets/Asmr-B6ZiqkmU.js +2 -0
  10. package/client/dist/assets/Asmr-Cq2ua6Zk.css +1 -0
  11. package/client/dist/assets/Asmr.module-ChUXDZKx.js +1 -0
  12. package/client/dist/assets/AsmrResetProgressModal-Cqsoo3XC.js +1 -0
  13. package/client/dist/assets/AvSyncCalibrator-C1_iBSyL.css +1 -0
  14. package/client/dist/assets/AvSyncCalibrator-C8ZoIwMY.js +10 -0
  15. package/client/dist/assets/ErrorMessage-CXoRbrLN.css +1 -0
  16. package/client/dist/assets/ErrorMessage-z4f4WGgP.js +1 -0
  17. package/client/dist/assets/Home-DtRtTuI9.css +1 -0
  18. package/client/dist/assets/Home-thJhPfs1.js +1 -0
  19. package/client/dist/assets/Insights-DKtoVe6L.css +1 -0
  20. package/client/dist/assets/Insights-ZlgPrzGD.js +1 -0
  21. package/client/dist/assets/LibraryListPage-CCiee-Ot.js +1 -0
  22. package/client/dist/assets/ListeningList-ctAz_6Zi.js +1 -0
  23. package/client/dist/assets/Manga-BI9WMTx5.js +1 -0
  24. package/client/dist/assets/Manga-Dp_-l1Pw.css +1 -0
  25. package/client/dist/assets/Manga.module-BcfOdsNP.js +1 -0
  26. package/client/dist/assets/MangaInfoPage-DcrEr7Sk.js +1 -0
  27. package/client/dist/assets/MangaReadPage-SYg63m48.js +1 -0
  28. package/client/dist/assets/MangaResetProgressModal-DX-1PS4h.js +1 -0
  29. package/client/dist/assets/Map-CXFqXmsZ.css +1 -0
  30. package/client/dist/assets/Map-Dj51g5zL.js +1 -0
  31. package/client/dist/assets/Mature-DJmUUtlF.css +1 -0
  32. package/client/dist/assets/Mature-Dqh_9SA3.js +1 -0
  33. package/client/dist/assets/MediaCard-Be1ST9oj.js +1 -0
  34. package/client/dist/assets/MediaCard-DB6Yjgky.css +1 -0
  35. package/client/dist/assets/Music-BGH_0voi.css +1 -0
  36. package/client/dist/assets/Music-zsPwT8_5.js +1 -0
  37. package/client/dist/assets/OptionListMenu-Drc_vybW.js +1 -0
  38. package/client/dist/assets/Player-B036Ic3m.css +1 -0
  39. package/client/dist/assets/Player-BEtAYps2.js +56 -0
  40. package/client/dist/assets/PlayerRedirect-D70t8VUW.js +1 -0
  41. package/client/dist/assets/PlayerSettings-Bj6SsmQ2.css +1 -0
  42. package/client/dist/assets/PlayerSettings-Cwoji-P7.js +1 -0
  43. package/client/dist/assets/QueueOptionsButton-B6BMq66e.js +1 -0
  44. package/client/dist/assets/QueueOptionsButton-CM15uQn8.css +1 -0
  45. package/client/dist/assets/QueueRail-BUYEjdaq.css +1 -0
  46. package/client/dist/assets/QueueRail-VxpUDBSm.js +1 -0
  47. package/client/dist/assets/Radio-B2Q_Egj7.js +1 -0
  48. package/client/dist/assets/Radio-CCHNFN7R.css +1 -0
  49. package/client/dist/assets/Radio.module-DGSZLpKM.js +1 -0
  50. package/client/dist/assets/ReadingList-O0QPwXu7.js +1 -0
  51. package/client/dist/assets/ResetProgressModal-CR_ovLAu.js +1 -0
  52. package/client/dist/assets/Search-C8e2nlDD.js +1 -0
  53. package/client/dist/assets/Search-Cs45oAwI.css +1 -0
  54. package/client/dist/assets/Search.module-CMEai-B-.js +1 -0
  55. package/client/dist/assets/Settings-AuFxQcJF.css +1 -0
  56. package/client/dist/assets/Settings-BgPWBl2V.js +21 -0
  57. package/client/dist/assets/SkeletonGrid-CtHKKMLj.js +1 -0
  58. package/client/dist/assets/SkeletonGrid-DhPYefXr.css +1 -0
  59. package/client/dist/assets/SynopsisText-BcpwT5iw.js +1 -0
  60. package/client/dist/assets/SynopsisText-CYv2W3yR.css +1 -0
  61. package/client/dist/assets/Trackers-Bfoiyuzy.js +2 -0
  62. package/client/dist/assets/Trackers-D5XBOCDp.css +1 -0
  63. package/client/dist/assets/Tv-Ca0fCdko.js +3 -0
  64. package/client/dist/assets/Tv-CuEl3Iol.css +1 -0
  65. package/client/dist/assets/TvInfo-Bu36xJyO.css +1 -0
  66. package/client/dist/assets/TvInfo-D_scgw_h.js +1 -0
  67. package/client/dist/assets/TvPopup-YTI7dEbJ.js +1 -0
  68. package/client/dist/assets/TvSearch-CxU33UOf.js +1 -0
  69. package/client/dist/assets/TvWatchlist-B4X1fWEH.js +1 -0
  70. package/client/dist/assets/VirtualKeyboard-6ZNTym4R.js +1 -0
  71. package/client/dist/assets/VirtualKeyboard-KIPc5ZGH.css +1 -0
  72. package/client/dist/assets/Watchlist-BiDEU0iV.css +1 -0
  73. package/client/dist/assets/Watchlist-P6YMZUdF.js +1 -0
  74. package/client/dist/assets/Watchlist.module-XGtVQr1d.js +1 -0
  75. package/client/dist/assets/hls-BOLXTnjL.js +42 -0
  76. package/client/dist/assets/hls-C4cG9wQw.js +1 -0
  77. package/client/dist/assets/index-BOKlZEHq.js +30354 -0
  78. package/client/dist/assets/index-CUm1meqr.css +1 -0
  79. package/client/dist/assets/index-IZgh1beL.js +178 -0
  80. package/client/dist/assets/useAnimeInfoData-DeuwDSnI.js +1 -0
  81. package/client/dist/assets/useAsmr-CbMVRjmV.js +1 -0
  82. package/client/dist/assets/useAsmrPopup-D91Q3CBg.js +1 -0
  83. package/client/dist/assets/useIsMobile-Dm_b6Ob3.js +1 -0
  84. package/client/dist/assets/useMangaLibrary-BPvFb1lU.js +1 -0
  85. package/client/dist/assets/useMangaPopup-BOL2mQJs.js +1 -0
  86. package/client/dist/assets/useMatureConsent-CT7-KfzG.js +1 -0
  87. package/client/dist/assets/useProviders-BsRGONMA.js +1 -0
  88. package/client/dist/assets/useTv-D37MfTHA.js +1 -0
  89. package/client/dist/assets/useTvLibrary-Cwxgy_Bg.js +1 -0
  90. package/client/dist/assets/vendor-core-8R2fFbyN.js +1 -0
  91. package/client/dist/assets/vendor-icons-CDMuNDeL.js +1 -0
  92. package/client/dist/assets/vendor-query-BXRECSI6.js +1 -0
  93. package/client/dist/assets/vendor-ui-CUDCvRS_.js +1 -0
  94. package/client/dist/av-sync-test.mp4 +0 -0
  95. package/client/dist/favicon.ico +0 -0
  96. package/client/dist/index.html +46 -0
  97. package/client/dist/logo-mochi.png +0 -0
  98. package/client/dist/logo-sakura.png +0 -0
  99. package/client/dist/logo.png +0 -0
  100. package/client/dist/placeholder.svg +4 -0
  101. package/client/dist/robots.txt +3 -0
  102. package/orchestrator.js +534 -0
  103. package/package.json +85 -0
  104. package/server/dist/app-auth.js +177 -0
  105. package/server/dist/config.js +175 -0
  106. package/server/dist/controllers/asmr-library.controller.js +288 -0
  107. package/server/dist/controllers/auth.controller.js +225 -0
  108. package/server/dist/controllers/data.controller.js +766 -0
  109. package/server/dist/controllers/insights.controller.js +198 -0
  110. package/server/dist/controllers/manga-library.controller.js +296 -0
  111. package/server/dist/controllers/music.controller.js +698 -0
  112. package/server/dist/controllers/proxy.controller.js +820 -0
  113. package/server/dist/controllers/settings.controller.js +609 -0
  114. package/server/dist/controllers/tv-library.controller.js +310 -0
  115. package/server/dist/controllers/watchlist.controller.js +1015 -0
  116. package/server/dist/db.js +145 -0
  117. package/server/dist/discord-gateway.js +391 -0
  118. package/server/dist/discord-rpc.js +427 -0
  119. package/server/dist/github-sync.js +512 -0
  120. package/server/dist/google.js +748 -0
  121. package/server/dist/lib/anilist.js +1549 -0
  122. package/server/dist/lib/anischedule.js +194 -0
  123. package/server/dist/lib/discord-roles-sync.service.js +130 -0
  124. package/server/dist/lib/ipc.js +24 -0
  125. package/server/dist/lib/kitsu.js +544 -0
  126. package/server/dist/lib/mal.js +934 -0
  127. package/server/dist/lib/migration.js +329 -0
  128. package/server/dist/lib/offline-db.js +401 -0
  129. package/server/dist/lib/temp-ids.js +3 -0
  130. package/server/dist/lib/tmdb.js +72 -0
  131. package/server/dist/lib/tracker/anilist-tracker.js +245 -0
  132. package/server/dist/lib/tracker/sync.service.js +496 -0
  133. package/server/dist/lib/ytmusic.js +125 -0
  134. package/server/dist/logger.js +16 -0
  135. package/server/dist/providers/manga/manga.types.js +7 -0
  136. package/server/dist/providers/provider.interface.js +1 -0
  137. package/server/dist/providers/remote-loader.js +574 -0
  138. package/server/dist/providers/remote-types.js +117 -0
  139. package/server/dist/providers/title-matching.js +225 -0
  140. package/server/dist/providers/tv.types.js +1 -0
  141. package/server/dist/rclone.js +120 -0
  142. package/server/dist/repositories/asmr.repository.js +123 -0
  143. package/server/dist/repositories/insights.repository.js +85 -0
  144. package/server/dist/repositories/library.repository.js +108 -0
  145. package/server/dist/repositories/mal-cache.repository.js +55 -0
  146. package/server/dist/repositories/manga.repository.js +138 -0
  147. package/server/dist/repositories/notifications.repository.js +41 -0
  148. package/server/dist/repositories/queue.repository.js +61 -0
  149. package/server/dist/repositories/settings.repository.js +12 -0
  150. package/server/dist/repositories/shows-meta.repository.js +42 -0
  151. package/server/dist/repositories/temp-show-ids.repository.js +51 -0
  152. package/server/dist/repositories/tv.repository.js +151 -0
  153. package/server/dist/repositories/watched-episodes.repository.js +52 -0
  154. package/server/dist/repositories/watchlist.repository.js +71 -0
  155. package/server/dist/request-logger.js +16 -0
  156. package/server/dist/routes/asmr-library.routes.js +23 -0
  157. package/server/dist/routes/asmr.routes.js +81 -0
  158. package/server/dist/routes/auth.routes.js +25 -0
  159. package/server/dist/routes/data.routes.js +47 -0
  160. package/server/dist/routes/discord-gateway.routes.js +51 -0
  161. package/server/dist/routes/insights.routes.js +34 -0
  162. package/server/dist/routes/lan-auth.routes.js +46 -0
  163. package/server/dist/routes/manga-library.routes.js +23 -0
  164. package/server/dist/routes/manga.routes.js +141 -0
  165. package/server/dist/routes/music.routes.js +20 -0
  166. package/server/dist/routes/providers.routes.js +17 -0
  167. package/server/dist/routes/proxy.routes.js +12 -0
  168. package/server/dist/routes/radio.routes.js +138 -0
  169. package/server/dist/routes/settings.routes.js +24 -0
  170. package/server/dist/routes/tracker.routes.js +164 -0
  171. package/server/dist/routes/translate.routes.js +88 -0
  172. package/server/dist/routes/tv-library.routes.js +23 -0
  173. package/server/dist/routes/tv.routes.js +629 -0
  174. package/server/dist/routes/watchlist.routes.js +341 -0
  175. package/server/dist/scripts/backfill-episode-duration.js +42 -0
  176. package/server/dist/server.js +529 -0
  177. package/server/dist/shipped-defaults.js +7 -0
  178. package/server/dist/sync-config.js +23 -0
  179. package/server/dist/sync-payload.js +91 -0
  180. package/server/dist/sync.js +1128 -0
  181. package/server/dist/utils/cache.utils.js +25 -0
  182. package/server/dist/utils/cookie.utils.js +13 -0
  183. package/server/dist/utils/db-utils.js +9 -0
  184. package/server/dist/utils/env.utils.js +77 -0
  185. package/server/dist/utils/http.utils.js +70 -0
  186. package/server/dist/utils/machine-id.js +40 -0
  187. package/server/dist/utils/megaplay.utils.js +41 -0
  188. package/server/dist/utils/request-context.js +2 -0
  189. package/server/dist/utils/security.utils.js +94 -0
  190. 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(/&amp;/g, '&')
380
+ .replace(/&quot;/g, '"')
381
+ .replace(/&#0?39;|&apos;/g, "'")
382
+ .replace(/&lt;/g, '<')
383
+ .replace(/&gt;/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
+ }