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