@serifpersia/dango 3.0.3

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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -0
  3. package/client/dist/assets/AnimeInfo-CMxs0nRH.css +1 -0
  4. package/client/dist/assets/AnimeInfo-DVdaocCl.js +1 -0
  5. package/client/dist/assets/AnimeInfoPage-Bx5EvGt-.js +2 -0
  6. package/client/dist/assets/Asmr-C2i9GKD4.css +1 -0
  7. package/client/dist/assets/Asmr-CIjmFtRH.js +2 -0
  8. package/client/dist/assets/Asmr.module-DPG2JhDJ.js +1 -0
  9. package/client/dist/assets/AvSyncCalibrator-CIYcyon5.css +1 -0
  10. package/client/dist/assets/AvSyncCalibrator-vqErH7Cm.js +10 -0
  11. package/client/dist/assets/ErrorMessage-BwSjzblw.js +1 -0
  12. package/client/dist/assets/ErrorMessage-CXoRbrLN.css +1 -0
  13. package/client/dist/assets/Home-D7YfGBUk.js +1 -0
  14. package/client/dist/assets/Home-DKCfEiwl.css +1 -0
  15. package/client/dist/assets/Insights-BljI64Ua.css +1 -0
  16. package/client/dist/assets/Insights-CLQGrlpX.js +1 -0
  17. package/client/dist/assets/Manga-DQ9HYnpk.js +1 -0
  18. package/client/dist/assets/Manga-H8Wb04w3.css +1 -0
  19. package/client/dist/assets/Map-CXFqXmsZ.css +1 -0
  20. package/client/dist/assets/Map-L_ryVBTq.js +1 -0
  21. package/client/dist/assets/Mature-BeWzAJ7B.js +1 -0
  22. package/client/dist/assets/Mature-DJmUUtlF.css +1 -0
  23. package/client/dist/assets/Player-CYpd__FP.js +30 -0
  24. package/client/dist/assets/Player-DreAhybc.css +1 -0
  25. package/client/dist/assets/PlayerRedirect-fxUAp9JV.js +1 -0
  26. package/client/dist/assets/PlayerSettings-BcsotxP9.js +1 -0
  27. package/client/dist/assets/PlayerSettings-Cqua1CDD.css +1 -0
  28. package/client/dist/assets/QueueRail-B9Pgbqc3.js +1 -0
  29. package/client/dist/assets/QueueRail-BnaltOP8.css +1 -0
  30. package/client/dist/assets/Radio-BvdY5twD.js +1 -0
  31. package/client/dist/assets/Radio-CUuYy3DY.css +1 -0
  32. package/client/dist/assets/Search-BzO-aRP7.css +1 -0
  33. package/client/dist/assets/Search-R9YLAXS1.js +1 -0
  34. package/client/dist/assets/Search.module-D8ZbQAiK.js +1 -0
  35. package/client/dist/assets/Settings-9kYcXdTR.js +21 -0
  36. package/client/dist/assets/Settings-DF8JThUL.css +1 -0
  37. package/client/dist/assets/SkeletonGrid-D5vRvX5q.js +1 -0
  38. package/client/dist/assets/SkeletonGrid-DEbYtO4H.css +1 -0
  39. package/client/dist/assets/SynopsisText-BlFw0R-0.js +1 -0
  40. package/client/dist/assets/SynopsisText-CUgOjOxt.css +1 -0
  41. package/client/dist/assets/Trackers-DfZlnnVq.js +2 -0
  42. package/client/dist/assets/Trackers-Dt9lR_uP.css +1 -0
  43. package/client/dist/assets/Tv-CVKC2YBs.css +1 -0
  44. package/client/dist/assets/Tv-Db1Wx_b7.js +15 -0
  45. package/client/dist/assets/Watchlist-B2nkNZqM.css +1 -0
  46. package/client/dist/assets/Watchlist-a77CYtzu.js +1 -0
  47. package/client/dist/assets/hls-6Owgw2Aj.js +1 -0
  48. package/client/dist/assets/hls-BOLXTnjL.js +42 -0
  49. package/client/dist/assets/index-B6qdXFtl.css +1 -0
  50. package/client/dist/assets/index-B8TPVaIS.js +30354 -0
  51. package/client/dist/assets/index-CpjgU8ou.js +178 -0
  52. package/client/dist/assets/useAnimeInfoData-BOfyi9UJ.js +1 -0
  53. package/client/dist/assets/useIsMobile-CM15uQn8.css +1 -0
  54. package/client/dist/assets/useIsMobile-Cb8HcKRj.js +1 -0
  55. package/client/dist/assets/useMatureConsent-CZMYBVXd.js +1 -0
  56. package/client/dist/assets/vendor-rLNfZm7Y.js +1 -0
  57. package/client/dist/av-sync-test.mp4 +0 -0
  58. package/client/dist/favicon.ico +0 -0
  59. package/client/dist/index.html +31 -0
  60. package/client/dist/logo-mochi.png +0 -0
  61. package/client/dist/logo-sakura.png +0 -0
  62. package/client/dist/logo.png +0 -0
  63. package/client/dist/placeholder.svg +4 -0
  64. package/client/dist/robots.txt +3 -0
  65. package/orchestrator.js +416 -0
  66. package/package.json +89 -0
  67. package/server/dist/app-auth.js +231 -0
  68. package/server/dist/config.js +133 -0
  69. package/server/dist/controllers/auth.controller.js +247 -0
  70. package/server/dist/controllers/data.controller.js +785 -0
  71. package/server/dist/controllers/insights.controller.js +197 -0
  72. package/server/dist/controllers/proxy.controller.js +665 -0
  73. package/server/dist/controllers/settings.controller.js +267 -0
  74. package/server/dist/controllers/watchlist.controller.js +967 -0
  75. package/server/dist/db.js +152 -0
  76. package/server/dist/discord-gateway.js +381 -0
  77. package/server/dist/discord-rpc.js +424 -0
  78. package/server/dist/github-sync.js +403 -0
  79. package/server/dist/google.js +549 -0
  80. package/server/dist/lib/anilist.js +1519 -0
  81. package/server/dist/lib/anischedule.js +202 -0
  82. package/server/dist/lib/ipc.js +32 -0
  83. package/server/dist/lib/kitsu.js +552 -0
  84. package/server/dist/lib/mal.js +886 -0
  85. package/server/dist/lib/migration.js +335 -0
  86. package/server/dist/lib/temp-ids.js +11 -0
  87. package/server/dist/lib/tmdb.js +71 -0
  88. package/server/dist/lib/tracker/anilist-tracker.js +250 -0
  89. package/server/dist/lib/tracker/sync.service.js +312 -0
  90. package/server/dist/logger.js +19 -0
  91. package/server/dist/providers/123anime.provider.js +240 -0
  92. package/server/dist/providers/anilight.provider.js +366 -0
  93. package/server/dist/providers/animepahe.provider.js +531 -0
  94. package/server/dist/providers/animeya.provider.js +746 -0
  95. package/server/dist/providers/base-provider.js +45 -0
  96. package/server/dist/providers/hn.provider.js +473 -0
  97. package/server/dist/providers/ht.provider.js +439 -0
  98. package/server/dist/providers/jasmr.provider.js +504 -0
  99. package/server/dist/providers/kaa.provider.js +388 -0
  100. package/server/dist/providers/manga/manga.types.js +10 -0
  101. package/server/dist/providers/manga/mangadex.provider.js +227 -0
  102. package/server/dist/providers/manga/mangapill.provider.js +232 -0
  103. package/server/dist/providers/megaplay.provider.js +583 -0
  104. package/server/dist/providers/op.provider.js +440 -0
  105. package/server/dist/providers/provider.interface.js +2 -0
  106. package/server/dist/providers/title-matching.js +234 -0
  107. package/server/dist/providers/wh.provider.js +600 -0
  108. package/server/dist/rclone.js +126 -0
  109. package/server/dist/repositories/insights.repository.js +43 -0
  110. package/server/dist/repositories/mal-cache.repository.js +66 -0
  111. package/server/dist/repositories/notifications.repository.js +40 -0
  112. package/server/dist/repositories/queue.repository.js +64 -0
  113. package/server/dist/repositories/settings.repository.js +15 -0
  114. package/server/dist/repositories/shows-meta.repository.js +43 -0
  115. package/server/dist/repositories/temp-show-ids.repository.js +54 -0
  116. package/server/dist/repositories/watched-episodes.repository.js +54 -0
  117. package/server/dist/repositories/watchlist.repository.js +74 -0
  118. package/server/dist/routes/asmr.routes.js +72 -0
  119. package/server/dist/routes/auth.routes.js +28 -0
  120. package/server/dist/routes/data.routes.js +50 -0
  121. package/server/dist/routes/discord-gateway.routes.js +57 -0
  122. package/server/dist/routes/insights.routes.js +12 -0
  123. package/server/dist/routes/lan-auth.routes.js +49 -0
  124. package/server/dist/routes/manga.routes.js +115 -0
  125. package/server/dist/routes/proxy.routes.js +15 -0
  126. package/server/dist/routes/radio.routes.js +143 -0
  127. package/server/dist/routes/settings.routes.js +27 -0
  128. package/server/dist/routes/tracker.routes.js +143 -0
  129. package/server/dist/routes/translate.routes.js +90 -0
  130. package/server/dist/routes/tv.routes.js +843 -0
  131. package/server/dist/routes/watchlist.routes.js +312 -0
  132. package/server/dist/server.js +310 -0
  133. package/server/dist/shipped-defaults.js +8 -0
  134. package/server/dist/sync-config.js +32 -0
  135. package/server/dist/sync.js +506 -0
  136. package/server/dist/utils/cookie.utils.js +17 -0
  137. package/server/dist/utils/db-utils.js +15 -0
  138. package/server/dist/utils/env.utils.js +83 -0
  139. package/server/dist/utils/machine-id.js +46 -0
  140. package/server/dist/utils/request-context.js +5 -0
  141. package/server/dist/utils/security.utils.js +99 -0
@@ -0,0 +1,552 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.kitsuSearchAnime = kitsuSearchAnime;
7
+ exports.dedupeKitsuMedia = dedupeKitsuMedia;
8
+ exports.kitsuBatchGetStatuses = kitsuBatchGetStatuses;
9
+ exports.kitsuMetaByAnilistId = kitsuMetaByAnilistId;
10
+ exports.kitsuMetaByMalId = kitsuMetaByMalId;
11
+ exports.kitsuTitlesByMalIds = kitsuTitlesByMalIds;
12
+ exports.kitsuEpisodes = kitsuEpisodes;
13
+ exports.kitsuTrending = kitsuTrending;
14
+ exports.kitsuSpotlight = kitsuSpotlight;
15
+ exports.kitsuLatestReleases = kitsuLatestReleases;
16
+ exports.kitsuSeasonal = kitsuSeasonal;
17
+ const logger_1 = __importDefault(require("../logger"));
18
+ const KITSU_BASE = 'https://kitsu.app/api/edge';
19
+ const KITSU_HEADERS = { Accept: 'application/vnd.api+json', 'User-Agent': 'dango' };
20
+ const REQUEST_TIMEOUT_MS = 15000;
21
+ const MAX_RETRIES = 3;
22
+ async function kitsuFetch(path) {
23
+ let lastError = null;
24
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
25
+ try {
26
+ const controller = new AbortController();
27
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
28
+ try {
29
+ const res = await fetch(`${KITSU_BASE}${path}`, {
30
+ headers: KITSU_HEADERS,
31
+ signal: controller.signal,
32
+ });
33
+ if (res.status === 429 || res.status >= 500) {
34
+ lastError = `kitsu fetch failed with status ${res.status}`;
35
+ await new Promise((r) => setTimeout(r, 1000 * attempt));
36
+ continue;
37
+ }
38
+ if (!res.ok) {
39
+ logger_1.default.warn({ status: res.status, path }, 'Kitsu request failed');
40
+ return null;
41
+ }
42
+ return (await res.json());
43
+ }
44
+ finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
48
+ catch (err) {
49
+ lastError = err instanceof Error ? err.message : String(err);
50
+ if (attempt < MAX_RETRIES)
51
+ await new Promise((r) => setTimeout(r, 500 * attempt));
52
+ }
53
+ }
54
+ logger_1.default.warn({ lastError }, 'Kitsu request error');
55
+ return null;
56
+ }
57
+ function asArray(data) {
58
+ if (!data)
59
+ return [];
60
+ return Array.isArray(data) ? data : [data];
61
+ }
62
+ /** Builds a map of kitsu relationship id -> included resource for a given type. */
63
+ function includedMap(entry, included, type) {
64
+ if (!included || !entry.relationships)
65
+ return [];
66
+ const rel = entry.relationships[type];
67
+ if (!rel?.data)
68
+ return [];
69
+ const ids = new Set();
70
+ for (const item of Array.isArray(rel.data) ? rel.data : [rel.data]) {
71
+ ids.add(item.id);
72
+ }
73
+ return included.filter((i) => i.type === type && ids.has(i.id));
74
+ }
75
+ /** Kitsu status/subtype/season values are lowercase; map to AniList-style values. */
76
+ const STATUS_MAP = {
77
+ current: 'RELEASING',
78
+ finished: 'FINISHED',
79
+ upcoming: 'NOT_YET_RELEASED',
80
+ unreleased: 'NOT_YET_RELEASED',
81
+ tba: 'NOT_YET_RELEASED',
82
+ };
83
+ const SUBTYPE_MAP = {
84
+ tv: 'TV',
85
+ ova: 'OVA',
86
+ ona: 'ONA',
87
+ movie: 'MOVIE',
88
+ special: 'SPECIAL',
89
+ music: 'MUSIC',
90
+ };
91
+ const SEASON_BY_MONTH = [
92
+ 'WINTER',
93
+ 'WINTER',
94
+ 'SPRING',
95
+ 'SPRING',
96
+ 'SPRING',
97
+ 'SUMMER',
98
+ 'SUMMER',
99
+ 'SUMMER',
100
+ 'FALL',
101
+ 'FALL',
102
+ 'FALL',
103
+ 'WINTER',
104
+ ];
105
+ function parseKitsuDate(s) {
106
+ if (typeof s !== 'string' || !s)
107
+ return undefined;
108
+ const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
109
+ if (!m)
110
+ return undefined;
111
+ return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
112
+ }
113
+ function statusToAnilist(status) {
114
+ if (!status)
115
+ return undefined;
116
+ return STATUS_MAP[status.toLowerCase()] ?? status.toUpperCase();
117
+ }
118
+ function formatToAnilist(subtype) {
119
+ if (!subtype)
120
+ return undefined;
121
+ return SUBTYPE_MAP[subtype.toLowerCase()] ?? subtype.toUpperCase();
122
+ }
123
+ function anilistSortToKitsu(sort) {
124
+ switch (sort) {
125
+ case 'SCORE_DESC':
126
+ return '-averageRating';
127
+ case 'FAVOURITES_DESC':
128
+ return '-favoritesCount';
129
+ case 'START_DATE_DESC':
130
+ return '-startDate';
131
+ case 'END_DATE_DESC':
132
+ return '-endDate';
133
+ case 'EPISODES_DESC':
134
+ return '-episodeCount';
135
+ case 'ID_DESC':
136
+ return '-id';
137
+ case 'UPDATED_AT_DESC':
138
+ return '-updatedAt';
139
+ default:
140
+ return '-userCount';
141
+ }
142
+ }
143
+ /** Translates an AniList-style status filter to a Kitsu filter value. */
144
+ function statusToKitsuFilter(status) {
145
+ if (!status)
146
+ return undefined;
147
+ const s = status.toUpperCase();
148
+ if (s === 'RELEASING' || s === 'ONGOING')
149
+ return 'current';
150
+ if (s === 'FINISHED')
151
+ return 'finished';
152
+ if (s === 'NOT_YET_RELEASED' || s === 'UPCOMING' || s === 'HIATUS')
153
+ return 'upcoming';
154
+ return undefined;
155
+ }
156
+ function seasonToKitsu(season) {
157
+ if (!season)
158
+ return undefined;
159
+ return season.toLowerCase();
160
+ }
161
+ /** Kitsu genre-ish taxonomy: `categories`. */
162
+ const CATEGORY_ALIASES = {
163
+ 'Sci-Fi': 'Science Fiction',
164
+ 'Sci Fi': 'Science Fiction',
165
+ Shounen: 'Shounen',
166
+ };
167
+ function normalizeCategory(cat) {
168
+ const c = cat.trim();
169
+ const alias = Object.entries(CATEGORY_ALIASES).find(([k]) => k.toLowerCase() === c.toLowerCase());
170
+ return alias ? alias[1] : c;
171
+ }
172
+ /**
173
+ * Resolves the included anime resource linked by a mapping entry's `item` relationship.
174
+ */
175
+ function mappingItem(entry, included) {
176
+ const rel = entry.relationships?.item?.data;
177
+ if (!rel)
178
+ return undefined;
179
+ if (Array.isArray(rel))
180
+ return undefined;
181
+ return included.find((i) => i.type === rel.type && i.id === rel.id);
182
+ }
183
+ /**
184
+ * Fetches a list of anime from Kitsu and normalizes entries to AnilistMedia.
185
+ * `include=mappings,categories` gives AniList/MAL ids + genres in one request.
186
+ */
187
+ async function kitsuSearchAnime(options = {}) {
188
+ const { query, page = 1, perPage = 14, format, status, season, seasonYear, genre, genre_not_in, averageScore_greater, episodes_greater, isAdult, sort, } = options;
189
+ const filters = [];
190
+ if (query)
191
+ filters.push(`filter[text]=${encodeURIComponent(query)}`);
192
+ if (format && format !== 'ALL' && format !== 'ADULT') {
193
+ filters.push(`filter[subtype]=${format.toLowerCase()}`);
194
+ }
195
+ const statusFilter = statusToKitsuFilter(status);
196
+ if (statusFilter)
197
+ filters.push(`filter[status]=${statusFilter}`);
198
+ const seasonFilter = seasonToKitsu(season);
199
+ if (seasonFilter && seasonFilter !== 'all')
200
+ filters.push(`filter[season]=${seasonFilter}`);
201
+ if (seasonYear)
202
+ filters.push(`filter[seasonYear]=${seasonYear}`);
203
+ if (genre)
204
+ filters.push(`filter[categories]=${encodeURIComponent(genre)}`);
205
+ const offset = (page - 1) * perPage;
206
+ const path = `/anime?${filters.join('&')}${filters.length ? '&' : ''}sort=${anilistSortToKitsu(sort)}&page[limit]=${perPage}&page[offset]=${offset}&include=mappings,categories`;
207
+ const json = await kitsuFetch(path);
208
+ if (!json)
209
+ return [];
210
+ const entries = asArray(json.data);
211
+ const included = json.included ?? [];
212
+ const results = [];
213
+ for (const entry of entries) {
214
+ const media = normalizeKitsuEntry(entry, included);
215
+ if (!media)
216
+ continue;
217
+ if (genre_not_in?.length) {
218
+ const hasExcluded = (media.genres ?? []).some((g) => genre_not_in.some((ng) => ng.toLowerCase() === g.toLowerCase()));
219
+ if (hasExcluded)
220
+ continue;
221
+ }
222
+ if (averageScore_greater != null && (media.averageScore ?? 0) < averageScore_greater)
223
+ continue;
224
+ if (episodes_greater != null && (media.episodes ?? 0) < episodes_greater)
225
+ continue;
226
+ if (isAdult === true && !media.isAdult)
227
+ continue;
228
+ if (isAdult === false && media.isAdult)
229
+ continue;
230
+ results.push(media);
231
+ }
232
+ return results;
233
+ }
234
+ /** Deduplicates by the resolved AniList/MAL id (keeps the first occurrence). */
235
+ function dedupeKitsuMedia(list) {
236
+ const seen = new Set();
237
+ const out = [];
238
+ for (const m of list) {
239
+ const key = m.id > 0 ? m.id : (m.idMal ?? m.id);
240
+ if (seen.has(key))
241
+ continue;
242
+ seen.add(key);
243
+ out.push(m);
244
+ }
245
+ return out;
246
+ }
247
+ /** Converts a Kitsu entry + included resources into AnilistMedia (id = AniList or MAL id). */
248
+ function normalizeKitsuEntry(entry, included, knownIds) {
249
+ const a = entry.attributes;
250
+ const mappings = includedMap(entry, included, 'mappings');
251
+ const anilistMapping = mappings.find((m) => m.attributes?.externalSite === 'anilist/anime');
252
+ const malMapping = mappings.find((m) => m.attributes?.externalSite === 'myanimelist/anime');
253
+ const anilistId = anilistMapping
254
+ ? Number(anilistMapping.attributes.externalId)
255
+ : (knownIds?.anilistId ?? null);
256
+ const malId = malMapping ? Number(malMapping.attributes.externalId) : (knownIds?.malId ?? null);
257
+ if (!anilistId && !malId)
258
+ return null;
259
+ const id = anilistId ?? -Math.abs(malId);
260
+ const title = {};
261
+ const titles = a.titles ?? {};
262
+ title.romaji =
263
+ titles.en_jp || titles.en || entry.attributes.canonicalTitle;
264
+ title.english = titles.en || titles.en_us || title.romaji;
265
+ title.native = titles.ja_jp || titles.ja || title.romaji;
266
+ const startDate = parseKitsuDate(a.startDate);
267
+ const endDate = parseKitsuDate(a.endDate);
268
+ const poster = a.posterImage ?? {};
269
+ const cover = a.coverImage ?? {};
270
+ const posterUrl = poster.large || poster.original || poster.medium || poster.small || poster.tiny || null;
271
+ const coverUrl = cover.large || cover.original;
272
+ const genres = includedMap(entry, included, 'categories')
273
+ .map((c) => c.attributes?.title)
274
+ .filter(Boolean)
275
+ .map((g) => normalizeCategory(g));
276
+ const nextAiringAt = a.nextRelease != null ? Math.floor(new Date(a.nextRelease).getTime() / 1000) : null;
277
+ return {
278
+ id,
279
+ idMal: malId,
280
+ title,
281
+ bannerImage: coverUrl || null,
282
+ coverImage: posterUrl
283
+ ? { extraLarge: posterUrl, large: posterUrl, medium: posterUrl }
284
+ : undefined,
285
+ description: a.synopsis || a.description || null,
286
+ genres: genres.length > 0 ? genres : undefined,
287
+ averageScore: a.averageRating != null ? Math.round(Number(a.averageRating)) : null,
288
+ format: formatToAnilist(a.subtype),
289
+ status: statusToAnilist(a.status),
290
+ episodes: a.episodeCount ?? null,
291
+ duration: a.episodeLength ?? null,
292
+ season: startDate?.month ? SEASON_BY_MONTH[startDate.month - 1] : undefined,
293
+ seasonYear: startDate?.year ?? undefined,
294
+ startDate,
295
+ endDate,
296
+ isAdult: !!a.nsfw,
297
+ synonyms: a.abbreviatedTitles ?? undefined,
298
+ popularity: a.userCount ?? undefined,
299
+ favourites: a.favoritesCount ?? undefined,
300
+ nextAiringEpisode: nextAiringAt != null
301
+ ? {
302
+ episode: 0,
303
+ timeUntilAiring: nextAiringAt - Math.floor(Date.now() / 1000),
304
+ airingAt: nextAiringAt,
305
+ }
306
+ : null,
307
+ };
308
+ }
309
+ /**
310
+ * Finds the Kitsu entry id for a given AniList id via the mappings endpoint.
311
+ */
312
+ async function kitsuIdByAnilistId(anilistId) {
313
+ const json = await kitsuFetch(`/mappings?filter[externalSite]=anilist%2Fanime&filter[externalId]=${anilistId}&include=item`);
314
+ const entries = asArray(json?.data);
315
+ if (entries.length === 0)
316
+ return null;
317
+ return mappingItem(entries[0], json?.included ?? [])?.id ?? null;
318
+ }
319
+ const KITSU_STATUS_MAP = {
320
+ current: 'RELEASING',
321
+ finished: 'FINISHED',
322
+ upcoming: 'NOT_YET_RELEASED',
323
+ unreleased: 'NOT_YET_RELEASED',
324
+ tba: 'NOT_YET_RELEASED',
325
+ };
326
+ async function kitsuBatchGetStatuses(anilistIds) {
327
+ const result = new Map();
328
+ for (const id of anilistIds) {
329
+ try {
330
+ const json = await kitsuFetch(`/mappings?filter[externalSite]=anilist%2Fanime&filter[externalId]=${id}&include=item`);
331
+ const entries = asArray(json?.data);
332
+ if (entries.length === 0)
333
+ continue;
334
+ const included = json?.included ?? [];
335
+ const entry = entries[0];
336
+ const rel = entry.relationships?.item?.data;
337
+ if (!rel)
338
+ continue;
339
+ const relSingle = Array.isArray(rel) ? rel[0] : rel;
340
+ if (!relSingle)
341
+ continue;
342
+ const anime = included.find((i) => i.id === relSingle.id && i.type === 'anime');
343
+ if (!anime)
344
+ continue;
345
+ const raw = anime.attributes?.status?.toLowerCase();
346
+ const status = raw ? (KITSU_STATUS_MAP[raw] ?? raw.toUpperCase()) : undefined;
347
+ if (status)
348
+ result.set(id, status);
349
+ }
350
+ catch {
351
+ // skip failed lookups
352
+ }
353
+ }
354
+ return result;
355
+ }
356
+ /** Fetches full detail (genres + studios) for a Kitsu anime id. */
357
+ async function kitsuDetail(kitsuId, knownIds) {
358
+ const [animeJson, studiosJson] = await Promise.all([
359
+ kitsuFetch(`/anime/${kitsuId}?include=categories,mappings`),
360
+ kitsuFetch(`/anime/${kitsuId}/anime-productions?include=producer&page[limit]=10`),
361
+ ]);
362
+ if (!animeJson)
363
+ return null;
364
+ const entry = asArray(animeJson.data)[0];
365
+ if (!entry)
366
+ return null;
367
+ const media = normalizeKitsuEntry(entry, animeJson.included ?? [], knownIds);
368
+ if (!media)
369
+ return null;
370
+ const producers = (studiosJson?.included ?? [])
371
+ .filter((i) => i.type === 'producers')
372
+ .map((i) => ({ id: 0, name: i.attributes?.name ?? '' }))
373
+ .filter((p) => p.name.length > 0);
374
+ if (producers.length > 0) {
375
+ media.studios = { nodes: producers };
376
+ }
377
+ return media;
378
+ }
379
+ async function kitsuMetaByAnilistId(anilistId) {
380
+ const kitsuId = await kitsuIdByAnilistId(anilistId);
381
+ if (!kitsuId)
382
+ return null;
383
+ return kitsuDetail(kitsuId, { anilistId });
384
+ }
385
+ async function kitsuMetaByMalId(malId) {
386
+ const json = await kitsuFetch(`/mappings?filter[externalSite]=myanimelist%2Fanime&filter[externalId]=${malId}&include=item`);
387
+ const entries = asArray(json?.data);
388
+ if (entries.length === 0)
389
+ return null;
390
+ const item = mappingItem(entries[0], json?.included ?? []);
391
+ if (!item)
392
+ return null;
393
+ return kitsuDetail(item.id, { malId });
394
+ }
395
+ /** Batch-resolves English/Japanese titles for MAL ids via the mappings endpoint. */
396
+ async function kitsuTitlesByMalIds(malIds) {
397
+ const result = new Map();
398
+ const unique = [...new Set(malIds.filter((n) => Number.isFinite(n) && n > 0))];
399
+ const CHUNK = 20;
400
+ const chunks = [];
401
+ for (let i = 0; i < unique.length; i += CHUNK)
402
+ chunks.push(unique.slice(i, i + CHUNK));
403
+ await Promise.all(chunks.map(async (ids) => {
404
+ try {
405
+ const json = await kitsuFetch(`/mappings?filter[externalSite]=myanimelist%2Fanime&filter[externalId]=${ids.join(',')}&include=item&page[limit]=${CHUNK}`);
406
+ if (!json)
407
+ return;
408
+ const animeById = new Map();
409
+ for (const inc of json.included ?? []) {
410
+ if (inc.type === 'anime')
411
+ animeById.set(inc.id, inc);
412
+ }
413
+ for (const entry of asArray(json.data)) {
414
+ const rel = entry.relationships?.item?.data;
415
+ const single = Array.isArray(rel) ? rel[0] : rel;
416
+ if (!single)
417
+ continue;
418
+ const titles = (animeById.get(single.id)?.attributes?.titles ?? {});
419
+ const english = titles.en || titles.en_us || undefined;
420
+ const native = titles.ja_jp || undefined;
421
+ if (!english && !native)
422
+ continue;
423
+ const malId = Number(entry.attributes?.externalId);
424
+ if (!Number.isFinite(malId) || malId <= 0)
425
+ continue;
426
+ result.set(malId, { english, native });
427
+ }
428
+ }
429
+ catch {
430
+ // ignore
431
+ }
432
+ }));
433
+ return result;
434
+ }
435
+ /** Episode numbers for a show, via the episodes relationship. */
436
+ async function kitsuEpisodes(anilistId, idMal) {
437
+ let kitsuId = null;
438
+ if (anilistId > 0)
439
+ kitsuId = await kitsuIdByAnilistId(anilistId);
440
+ if (!kitsuId && idMal != null) {
441
+ const json = await kitsuFetch(`/mappings?filter[externalSite]=myanimelist%2Fanime&filter[externalId]=${idMal}&include=item`);
442
+ const entries = asArray(json?.data);
443
+ if (entries.length > 0) {
444
+ kitsuId = mappingItem(entries[0], json?.included ?? [])?.id ?? null;
445
+ }
446
+ }
447
+ if (!kitsuId)
448
+ return [];
449
+ const all = [];
450
+ const LIMIT = 20;
451
+ let offset = 0;
452
+ while (offset < 1000) {
453
+ const json = await kitsuFetch(`/anime/${kitsuId}/episodes?page[limit]=${LIMIT}&page[offset]=${offset}&sort=number`);
454
+ if (!json)
455
+ break;
456
+ const entries = asArray(json.data);
457
+ if (entries.length === 0)
458
+ break;
459
+ for (const e of entries) {
460
+ const n = e.attributes?.number;
461
+ if (typeof n === 'number' && Number.isFinite(n) && n > 0)
462
+ all.push(n);
463
+ }
464
+ if (entries.length < LIMIT)
465
+ break;
466
+ offset += LIMIT;
467
+ }
468
+ if (all.length === 0)
469
+ return [];
470
+ const max = Math.max(...all);
471
+ return Array.from({ length: max }, (_, i) => (i + 1).toString());
472
+ }
473
+ /** Trending proxy: currently-airing shows sorted by popularity (user count). */
474
+ async function kitsuTrending(page = 1, perPage = 20, status) {
475
+ const statusFilter = statusToKitsuFilter(status);
476
+ const filters = statusFilter ? `filter[status]=${statusFilter}` : '';
477
+ const offset = (page - 1) * perPage;
478
+ const json = await kitsuFetch(`/anime?${filters}${filters ? '&' : ''}sort=-userCount&page[limit]=${perPage}&page[offset]=${offset}&include=mappings,categories`);
479
+ if (!json)
480
+ return [];
481
+ return dedupeKitsuMedia(asArray(json.data)
482
+ .map((e) => normalizeKitsuEntry(e, json.included ?? []))
483
+ .filter((m) => m !== null));
484
+ }
485
+ /** Spotlight: top popular shows with native Kitsu banners (3360x800). */
486
+ async function kitsuSpotlight(page = 1, perPage = 20) {
487
+ const offset = (page - 1) * perPage;
488
+ const json = await kitsuFetch(`/anime?sort=-userCount&page[limit]=${perPage}&page[offset]=${offset}&include=mappings,categories`);
489
+ if (!json)
490
+ return [];
491
+ return dedupeKitsuMedia(asArray(json.data)
492
+ .map((e) => normalizeKitsuEntry(e, json.included ?? []))
493
+ .filter((m) => m !== null)
494
+ .filter((m) => !!m.bannerImage));
495
+ }
496
+ /**
497
+ * Latest releases: currently-airing shows. Kitsu has no reliable episode airdates,
498
+ * so we sort current shows by start date (newest first) and bump shows with a known
499
+ * next episode date to the front (soonest first).
500
+ */
501
+ async function kitsuLatestReleases(format = 'TV', page = 1, size = 12) {
502
+ const needed = page * size;
503
+ const formatFilter = format && format !== 'ALL' ? `filter[subtype]=${format.toLowerCase()}` : '';
504
+ const all = [];
505
+ let offset = 0;
506
+ const LIMIT = 20;
507
+ while (all.length < needed && offset < 500) {
508
+ const json = await kitsuFetch(`/anime?filter[status]=current${formatFilter ? `&${formatFilter}` : ''}&sort=-startDate&page[limit]=${LIMIT}&page[offset]=${offset}&include=mappings,categories`);
509
+ if (!json)
510
+ break;
511
+ const entries = asArray(json.data);
512
+ if (entries.length === 0)
513
+ break;
514
+ for (const e of entries) {
515
+ const m = normalizeKitsuEntry(e, json.included ?? []);
516
+ if (!m)
517
+ continue;
518
+ all.push(m);
519
+ }
520
+ offset += LIMIT;
521
+ }
522
+ all.sort((a, b) => {
523
+ const aNext = a.nextAiringEpisode?.airingAt ?? Number.MAX_SAFE_INTEGER;
524
+ const bNext = b.nextAiringEpisode?.airingAt ?? Number.MAX_SAFE_INTEGER;
525
+ if (aNext !== bNext)
526
+ return aNext - bNext;
527
+ const aStart = fallbackStartRank(a);
528
+ const bStart = fallbackStartRank(b);
529
+ if (aStart !== bStart)
530
+ return bStart - aStart;
531
+ return (b.popularity ?? 0) - (a.popularity ?? 0);
532
+ });
533
+ const start = (page - 1) * size;
534
+ return dedupeKitsuMedia(all.slice(start, start + size));
535
+ }
536
+ function fallbackStartRank(m) {
537
+ return ((m.startDate?.year ?? 0) * 10000 + (m.startDate?.month ?? 0) * 100 + (m.startDate?.day ?? 0));
538
+ }
539
+ /** Seasonal list for a given season/year, matching AniList's POPULARITY_DESC order. */
540
+ async function kitsuSeasonal(season, year, format, page = 1, size = 14) {
541
+ const filters = [`filter[season]=${seasonToKitsu(season)}`, `filter[seasonYear]=${year}`];
542
+ if (format && format !== 'ALL' && format !== 'ADULT') {
543
+ filters.push(`filter[subtype]=${format.toLowerCase()}`);
544
+ }
545
+ const offset = (page - 1) * size;
546
+ const json = await kitsuFetch(`/anime?${filters.join('&')}&sort=-userCount&page[limit]=${size}&page[offset]=${offset}&include=mappings,categories`);
547
+ if (!json)
548
+ return [];
549
+ return dedupeKitsuMedia(asArray(json.data)
550
+ .map((e) => normalizeKitsuEntry(e, json.included ?? []))
551
+ .filter((m) => m !== null));
552
+ }