@serifpersia/dango 3.6.7 → 3.6.9

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