cross-seed 6.0.0-4 → 6.0.0-40
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/dist/Result.js +17 -11
- package/dist/Result.js.map +1 -1
- package/dist/action.js +188 -74
- package/dist/action.js.map +1 -1
- package/dist/arr.js +197 -0
- package/dist/arr.js.map +1 -0
- package/dist/clients/Deluge.js +78 -55
- package/dist/clients/Deluge.js.map +1 -1
- package/dist/clients/QBittorrent.js +98 -67
- package/dist/clients/QBittorrent.js.map +1 -1
- package/dist/clients/RTorrent.js +39 -12
- package/dist/clients/RTorrent.js.map +1 -1
- package/dist/clients/TorrentClient.js +1 -1
- package/dist/clients/TorrentClient.js.map +1 -1
- package/dist/clients/Transmission.js +31 -11
- package/dist/clients/Transmission.js.map +1 -1
- package/dist/cmd.js +37 -23
- package/dist/cmd.js.map +1 -1
- package/dist/config.template.cjs +88 -52
- package/dist/config.template.cjs.map +1 -1
- package/dist/configSchema.js +102 -14
- package/dist/configSchema.js.map +1 -1
- package/dist/configuration.js +4 -1
- package/dist/configuration.js.map +1 -1
- package/dist/constants.js +114 -6
- package/dist/constants.js.map +1 -1
- package/dist/dataFiles.js +4 -5
- package/dist/dataFiles.js.map +1 -1
- package/dist/db.js +2 -1
- package/dist/db.js.map +1 -1
- package/dist/decide.js +282 -167
- package/dist/decide.js.map +1 -1
- package/dist/diff.js +13 -3
- package/dist/diff.js.map +1 -1
- package/dist/errors.js +5 -2
- package/dist/errors.js.map +1 -1
- package/dist/indexers.js +96 -16
- package/dist/indexers.js.map +1 -1
- package/dist/inject.js +410 -0
- package/dist/inject.js.map +1 -0
- package/dist/jobs.js +9 -2
- package/dist/jobs.js.map +1 -1
- package/dist/logger.js +29 -9
- package/dist/logger.js.map +1 -1
- package/dist/migrations/00-initialSchema.js.map +1 -1
- package/dist/migrations/05-caps.js +16 -0
- package/dist/migrations/05-caps.js.map +1 -0
- package/dist/migrations/06-uniqueDecisions.js +29 -0
- package/dist/migrations/06-uniqueDecisions.js.map +1 -0
- package/dist/migrations/07-limits.js +12 -0
- package/dist/migrations/07-limits.js.map +1 -0
- package/dist/migrations/migrations.js +13 -1
- package/dist/migrations/migrations.js.map +1 -1
- package/dist/parseTorrent.js +6 -0
- package/dist/parseTorrent.js.map +1 -1
- package/dist/pipeline.js +225 -116
- package/dist/pipeline.js.map +1 -1
- package/dist/preFilter.js +130 -52
- package/dist/preFilter.js.map +1 -1
- package/dist/pushNotifier.js +7 -5
- package/dist/pushNotifier.js.map +1 -1
- package/dist/runtimeConfig.js.map +1 -1
- package/dist/searchee.js +200 -19
- package/dist/searchee.js.map +1 -1
- package/dist/server.js +79 -32
- package/dist/server.js.map +1 -1
- package/dist/startup.js +17 -6
- package/dist/startup.js.map +1 -1
- package/dist/torrent.js +117 -51
- package/dist/torrent.js.map +1 -1
- package/dist/torznab.js +374 -146
- package/dist/torznab.js.map +1 -1
- package/dist/utils.js +252 -33
- package/dist/utils.js.map +1 -1
- package/package.json +10 -5
package/dist/torznab.js
CHANGED
@@ -1,22 +1,15 @@
|
|
1
|
+
import chalk from "chalk";
|
1
2
|
import ms from "ms";
|
3
|
+
import { inspect } from "util";
|
2
4
|
import xml2js from "xml2js";
|
3
|
-
import {
|
5
|
+
import { arrIdsEqual, formatFoundIds, getRelevantArrIds, scanAllArrsForMedia, } from "./arr.js";
|
6
|
+
import { CALIBRE_INDEXNUM_REGEX, EP_REGEX, SEASON_REGEX, UNKNOWN_TRACKER, USER_AGENT, } from "./constants.js";
|
4
7
|
import { db } from "./db.js";
|
5
8
|
import { CrossSeedError } from "./errors.js";
|
6
|
-
import { getEnabledIndexers, IndexerStatus, updateIndexerStatus, } from "./indexers.js";
|
7
|
-
import { Label, logger } from "./logger.js";
|
9
|
+
import { ALL_CAPS, getAllIndexers, getEnabledIndexers, IndexerStatus, updateIndexerCapsById, updateIndexerStatus, } from "./indexers.js";
|
10
|
+
import { Label, logger, logOnce } from "./logger.js";
|
8
11
|
import { getRuntimeConfig } from "./runtimeConfig.js";
|
9
|
-
import {
|
10
|
-
import { getAnimeQueries, cleanseSeparators, getTag, MediaType, nMsAgo, reformatTitleForSearching, stripExtension, } from "./utils.js";
|
11
|
-
function sanitizeUrl(url) {
|
12
|
-
if (typeof url === "string") {
|
13
|
-
url = new URL(url);
|
14
|
-
}
|
15
|
-
return url.origin + url.pathname;
|
16
|
-
}
|
17
|
-
function getApikey(url) {
|
18
|
-
return new URL(url).searchParams.get("apikey");
|
19
|
-
}
|
12
|
+
import { cleanTitle, combineAsyncIterables, extractInt, formatAsList, getAnimeQueries, getApikey, getLogString, getMediaType, isTruthy, MediaType, nMsAgo, reformatTitleForSearching, sanitizeUrl, stripExtension, stripMetaFromName, } from "./utils.js";
|
20
13
|
function parseTorznabResults(xml) {
|
21
14
|
const items = xml?.rss?.channel?.[0]?.item;
|
22
15
|
if (!items || !Array.isArray(items)) {
|
@@ -28,124 +21,274 @@ function parseTorznabResults(xml) {
|
|
28
21
|
tracker: item?.prowlarrindexer?.[0]?._ ??
|
29
22
|
item?.jackettindexer?.[0]?._ ??
|
30
23
|
item?.indexer?.[0]?._ ??
|
31
|
-
|
24
|
+
UNKNOWN_TRACKER,
|
32
25
|
link: item.link[0],
|
33
26
|
size: Number(item.size[0]),
|
34
27
|
pubDate: new Date(item.pubDate[0]).getTime(),
|
35
28
|
}));
|
36
29
|
}
|
37
30
|
function parseTorznabCaps(xml) {
|
38
|
-
const
|
31
|
+
const limits = xml?.caps?.limits?.map((limit) => ({
|
32
|
+
default: parseInt(limit.$.default),
|
33
|
+
max: parseInt(limit.$.max),
|
34
|
+
}))[0] ?? { default: 100, max: 100 };
|
35
|
+
const searchingSection = xml?.caps?.searching?.[0];
|
39
36
|
const isAvailable = (searchTechnique) => searchTechnique?.[0]?.$?.available === "yes";
|
37
|
+
function getSupportedIds(searchTechnique) {
|
38
|
+
const supportedParamsStr = searchTechnique?.[0]?.$?.supportedParams;
|
39
|
+
const supportedIds = supportedParamsStr
|
40
|
+
?.split(",")
|
41
|
+
?.filter((token) => token.includes("id")) ?? [];
|
42
|
+
return {
|
43
|
+
tvdbId: supportedIds.includes("tvdbid"),
|
44
|
+
tmdbId: supportedIds.includes("tmdbid"),
|
45
|
+
imdbId: supportedIds.includes("imdbid"),
|
46
|
+
tvMazeId: supportedIds.includes("tvmazeid"),
|
47
|
+
};
|
48
|
+
}
|
49
|
+
const categoryCaps = xml?.caps?.categories?.[0]?.category;
|
50
|
+
function getCatCaps(item) {
|
51
|
+
const categories = (item ?? []).map((cat) => ({
|
52
|
+
id: parseInt(cat.$.id),
|
53
|
+
name: cat.$.name.toLowerCase(),
|
54
|
+
}));
|
55
|
+
const caps = {
|
56
|
+
movie: false,
|
57
|
+
tv: false,
|
58
|
+
anime: false,
|
59
|
+
xxx: false,
|
60
|
+
audio: false,
|
61
|
+
book: false,
|
62
|
+
additional: false,
|
63
|
+
};
|
64
|
+
const keys = Object.keys(caps);
|
65
|
+
keys.splice(keys.indexOf("additional"), 1);
|
66
|
+
for (const { id, name } of categories) {
|
67
|
+
let isAdditional = true;
|
68
|
+
for (const cap of keys) {
|
69
|
+
if (name.includes(cap)) {
|
70
|
+
caps[cap] = true;
|
71
|
+
isAdditional = false;
|
72
|
+
}
|
73
|
+
}
|
74
|
+
if (isAdditional && id < 100000 && (id < 8000 || id > 8999)) {
|
75
|
+
caps.additional = true;
|
76
|
+
}
|
77
|
+
}
|
78
|
+
return caps;
|
79
|
+
}
|
40
80
|
return {
|
41
|
-
search: Boolean(isAvailable(
|
42
|
-
tvSearch: Boolean(isAvailable(
|
43
|
-
movieSearch: Boolean(isAvailable(
|
81
|
+
search: Boolean(isAvailable(searchingSection?.search)),
|
82
|
+
tvSearch: Boolean(isAvailable(searchingSection?.["tv-search"])),
|
83
|
+
movieSearch: Boolean(isAvailable(searchingSection?.["movie-search"])),
|
84
|
+
movieIdSearch: getSupportedIds(searchingSection?.["movie-search"]),
|
85
|
+
tvIdSearch: getSupportedIds(searchingSection?.["tv-search"]),
|
86
|
+
categories: getCatCaps(categoryCaps),
|
87
|
+
limits,
|
44
88
|
};
|
45
89
|
}
|
46
|
-
function createTorznabSearchQueries(searchee, caps) {
|
47
|
-
const
|
48
|
-
const
|
49
|
-
|
50
|
-
|
90
|
+
async function createTorznabSearchQueries(searchee, mediaType, caps, parsedMedia) {
|
91
|
+
const stem = stripExtension(searchee.title);
|
92
|
+
const relevantIds = parsedMedia
|
93
|
+
? await getRelevantArrIds(caps, parsedMedia)
|
94
|
+
: {};
|
95
|
+
const useIds = Object.values(relevantIds).some(isTruthy);
|
51
96
|
if (mediaType === MediaType.EPISODE && caps.tvSearch) {
|
52
|
-
const match =
|
97
|
+
const match = stem.match(EP_REGEX);
|
98
|
+
const groups = match.groups;
|
53
99
|
return [
|
54
100
|
{
|
55
101
|
t: "tvsearch",
|
56
|
-
q:
|
57
|
-
season:
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
: `${match.groups.month}/${match.groups.day}`,
|
102
|
+
q: useIds ? undefined : reformatTitleForSearching(stem),
|
103
|
+
season: groups.season ? extractInt(groups.season) : groups.year,
|
104
|
+
ep: groups.episode
|
105
|
+
? extractInt(groups.episode)
|
106
|
+
: `${groups.month}/${groups.day}`,
|
107
|
+
...relevantIds,
|
63
108
|
},
|
64
109
|
];
|
65
110
|
}
|
66
111
|
else if (mediaType === MediaType.SEASON && caps.tvSearch) {
|
67
|
-
const match =
|
112
|
+
const match = stem.match(SEASON_REGEX);
|
113
|
+
const groups = match.groups;
|
68
114
|
return [
|
69
115
|
{
|
70
116
|
t: "tvsearch",
|
71
|
-
q:
|
72
|
-
season:
|
117
|
+
q: useIds ? undefined : reformatTitleForSearching(stem),
|
118
|
+
season: extractInt(groups.season),
|
119
|
+
...relevantIds,
|
120
|
+
},
|
121
|
+
];
|
122
|
+
}
|
123
|
+
else if (mediaType === MediaType.MOVIE && caps.movieSearch) {
|
124
|
+
return [
|
125
|
+
{
|
126
|
+
t: "movie",
|
127
|
+
q: useIds ? undefined : reformatTitleForSearching(stem),
|
128
|
+
...relevantIds,
|
73
129
|
},
|
74
130
|
];
|
75
131
|
}
|
132
|
+
if (useIds && caps.tvSearch && parsedMedia?.series) {
|
133
|
+
const eps = parsedMedia.episodes;
|
134
|
+
const season = eps.length > 0 ? eps[0].seasonNumber : undefined;
|
135
|
+
const ep = eps.length === 1 ? eps[0].episodeNumber : undefined;
|
136
|
+
return [
|
137
|
+
{ t: "tvsearch", q: undefined, season, ep, ...relevantIds },
|
138
|
+
];
|
139
|
+
}
|
140
|
+
else if (useIds && caps.movieSearch && parsedMedia?.movie) {
|
141
|
+
return [{ t: "movie", q: undefined, ...relevantIds }];
|
142
|
+
}
|
76
143
|
else if (mediaType === MediaType.ANIME) {
|
77
|
-
|
78
|
-
return animeQueries.map((animeQuery) => ({
|
144
|
+
return getAnimeQueries(stem).map((animeQuery) => ({
|
79
145
|
t: "search",
|
80
146
|
q: animeQuery,
|
81
147
|
}));
|
82
148
|
}
|
83
|
-
else {
|
149
|
+
else if (mediaType === MediaType.VIDEO) {
|
84
150
|
return [
|
85
151
|
{
|
86
152
|
t: "search",
|
87
|
-
q:
|
153
|
+
q: cleanTitle(stripMetaFromName(stem)),
|
88
154
|
},
|
89
155
|
];
|
90
156
|
}
|
157
|
+
else if (mediaType === MediaType.BOOK && searchee.path) {
|
158
|
+
return [
|
159
|
+
{
|
160
|
+
t: "search",
|
161
|
+
q: cleanTitle(stem.replace(CALIBRE_INDEXNUM_REGEX, "")),
|
162
|
+
},
|
163
|
+
];
|
164
|
+
}
|
165
|
+
return [
|
166
|
+
{
|
167
|
+
t: "search",
|
168
|
+
q: cleanTitle(stem),
|
169
|
+
},
|
170
|
+
];
|
91
171
|
}
|
92
|
-
export async function
|
93
|
-
const
|
94
|
-
|
172
|
+
export async function getSearchString(searchee) {
|
173
|
+
const mediaType = getMediaType(searchee);
|
174
|
+
const params = (await createTorznabSearchQueries(searchee, mediaType, ALL_CAPS))[0];
|
175
|
+
const season = params.season !== undefined ? `.S${params.season}` : "";
|
176
|
+
const ep = params.ep !== undefined ? `.E${params.ep}` : "";
|
177
|
+
return `${params.q}${season}${ep}`.toLowerCase();
|
95
178
|
}
|
96
|
-
|
97
|
-
|
179
|
+
/**
|
180
|
+
* Only for testing purposes. (createTorznabSearchQueries now accepts searchee
|
181
|
+
* instead of stem (title))
|
182
|
+
*
|
183
|
+
* Logs the queries that would be sent to indexers for id and non-id searches.
|
184
|
+
* Ensure that item exists in your arr for the id search example.
|
185
|
+
* Ensure mediaType is what cross-seed would actually parse the item as.
|
186
|
+
*/
|
187
|
+
export async function logQueries(searcheeTitle, mediaType) {
|
188
|
+
const stem = stripExtension(searcheeTitle);
|
189
|
+
logger.info(
|
190
|
+
// @ts-expect-error needs conversion to use searchee instead of stem
|
191
|
+
`RAW: ${inspect(await createTorznabSearchQueries(stem, mediaType, ALL_CAPS))}`);
|
192
|
+
const res = await scanAllArrsForMedia(searcheeTitle, mediaType);
|
193
|
+
const parsedMedia = res.isOk() ? res.unwrap() : undefined;
|
194
|
+
logger.info(
|
195
|
+
// @ts-expect-error needs conversion to use searchee instead of stem
|
196
|
+
`ID: ${inspect(await createTorznabSearchQueries(stem, mediaType, ALL_CAPS, parsedMedia))}`);
|
197
|
+
}
|
198
|
+
export function indexerDoesSupportMediaType(mediaType, caps) {
|
199
|
+
switch (mediaType) {
|
200
|
+
case MediaType.EPISODE:
|
201
|
+
case MediaType.SEASON:
|
202
|
+
return caps.tv;
|
203
|
+
case MediaType.MOVIE:
|
204
|
+
return caps.movie;
|
205
|
+
case MediaType.ANIME:
|
206
|
+
case MediaType.VIDEO:
|
207
|
+
return caps.movie || caps.tv || caps.anime || caps.xxx;
|
208
|
+
case MediaType.AUDIO:
|
209
|
+
return caps.audio;
|
210
|
+
case MediaType.BOOK:
|
211
|
+
return caps.book;
|
212
|
+
case MediaType.OTHER:
|
213
|
+
return caps.additional;
|
214
|
+
}
|
215
|
+
}
|
216
|
+
export async function* rssPager(indexer, pageBackUntil) {
|
217
|
+
let earliestSeen = Infinity;
|
218
|
+
const limit = indexer.limits.max;
|
219
|
+
for (let i = 0; i < 10; i++) {
|
220
|
+
let currentPageCandidates;
|
221
|
+
try {
|
222
|
+
currentPageCandidates = await makeRequest({
|
223
|
+
indexerId: indexer.id,
|
224
|
+
baseUrl: indexer.url,
|
225
|
+
apikey: indexer.apikey,
|
226
|
+
query: { t: "search", q: "", limit, offset: i * limit },
|
227
|
+
});
|
228
|
+
}
|
229
|
+
catch (e) {
|
230
|
+
logger.error({
|
231
|
+
label: Label.TORZNAB,
|
232
|
+
message: `Paging indexer ${indexer.id} stopped: request failed for page ${i + 1}`,
|
233
|
+
});
|
234
|
+
logger.debug(e);
|
235
|
+
return;
|
236
|
+
}
|
237
|
+
const allNewPubDates = currentPageCandidates.map((c) => c.pubDate);
|
238
|
+
const currentPageEarliest = Math.min(...allNewPubDates);
|
239
|
+
const currentPageLatest = Math.max(...allNewPubDates);
|
240
|
+
const newCandidates = currentPageCandidates.filter((c) => c.pubDate < earliestSeen && c.pubDate >= pageBackUntil);
|
241
|
+
if (currentPageLatest > Date.now() + ms("10 minutes")) {
|
242
|
+
logOnce(`timezone-issues-${indexer.id}`, () => void logger.warn(`Indexer ${indexer.url} reported releases in the future. Its timezone may be misconfigured.`), ms("10 minutes"));
|
243
|
+
}
|
244
|
+
if (!newCandidates.length) {
|
245
|
+
logger.verbose({
|
246
|
+
label: Label.TORZNAB,
|
247
|
+
message: `Paging indexer ${indexer.id} stopped: nothing new in page ${i + 1}`,
|
248
|
+
});
|
249
|
+
return;
|
250
|
+
}
|
251
|
+
logger.verbose({
|
252
|
+
label: Label.TORZNAB,
|
253
|
+
message: `${newCandidates.length} new candidates on indexer ${indexer.id} page ${i + 1}`,
|
254
|
+
});
|
255
|
+
// yield each new candidate
|
256
|
+
yield* newCandidates;
|
257
|
+
earliestSeen = Math.min(earliestSeen, currentPageEarliest);
|
258
|
+
}
|
259
|
+
logger.verbose({
|
260
|
+
label: Label.TORZNAB,
|
261
|
+
message: `Paging indexer ${indexer.url} stopped: reached 10 pages`,
|
262
|
+
});
|
263
|
+
}
|
264
|
+
export async function* queryRssFeeds(pageBackUntil) {
|
265
|
+
const indexers = await getEnabledIndexers();
|
266
|
+
yield* combineAsyncIterables(indexers.map((indexer) => rssPager(indexer, pageBackUntil)));
|
267
|
+
}
|
268
|
+
export async function searchTorznab(searchee, cachedSearch, progress) {
|
269
|
+
const { torznab } = getRuntimeConfig();
|
98
270
|
if (torznab.length === 0) {
|
99
271
|
throw new Error("no indexers are available");
|
100
272
|
}
|
101
|
-
const
|
102
|
-
const
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
273
|
+
const mediaType = getMediaType(searchee);
|
274
|
+
const { indexersToSearch, parsedMedia } = await getAndLogIndexers(searchee, cachedSearch, mediaType, progress);
|
275
|
+
const indexerCandidates = await makeRequests(indexersToSearch, async (indexer) => {
|
276
|
+
const caps = {
|
277
|
+
search: indexer.searchCap,
|
278
|
+
tvSearch: indexer.tvSearchCap,
|
279
|
+
movieSearch: indexer.movieSearchCap,
|
280
|
+
tvIdSearch: indexer.tvIdCaps,
|
281
|
+
movieIdSearch: indexer.movieIdCaps,
|
282
|
+
categories: indexer.categories,
|
283
|
+
limits: indexer.limits,
|
284
|
+
};
|
285
|
+
return await createTorznabSearchQueries(searchee, mediaType, caps, parsedMedia);
|
113
286
|
});
|
114
|
-
|
115
|
-
const entry = timestampDataSql.find((entry) => entry.indexerId === indexer.id);
|
116
|
-
return (!entry ||
|
117
|
-
((!excludeOlder || entry.firstSearched > nMsAgo(excludeOlder)) &&
|
118
|
-
(!excludeRecentSearch ||
|
119
|
-
entry.lastSearched < nMsAgo(excludeRecentSearch))));
|
120
|
-
});
|
121
|
-
const timestampCallout = " (filtered by timestamps)";
|
122
|
-
logger.info({
|
123
|
-
label: Label.TORZNAB,
|
124
|
-
message: `Searching ${indexersToUse.length} indexers for ${name}${indexersToUse.length < enabledIndexers.length
|
125
|
-
? timestampCallout
|
126
|
-
: ""}`,
|
127
|
-
});
|
128
|
-
return makeRequests(indexersToUse, (indexer) => createTorznabSearchQueries(searchee, {
|
129
|
-
search: indexer.searchCap,
|
130
|
-
tvSearch: indexer.tvSearchCap,
|
131
|
-
movieSearch: indexer.movieSearchCap,
|
132
|
-
}));
|
287
|
+
return [...cachedSearch.indexerCandidates, ...indexerCandidates];
|
133
288
|
}
|
134
289
|
export async function syncWithDb() {
|
135
290
|
const { torznab } = getRuntimeConfig();
|
136
|
-
const dbIndexers = await
|
137
|
-
.where({ active: true })
|
138
|
-
.select({
|
139
|
-
id: "id",
|
140
|
-
url: "url",
|
141
|
-
apikey: "apikey",
|
142
|
-
active: "active",
|
143
|
-
status: "status",
|
144
|
-
retryAfter: "retry_after",
|
145
|
-
searchCap: "search_cap",
|
146
|
-
tvSearchCap: "tv_search_cap",
|
147
|
-
movieSearchCap: "movie_search_cap",
|
148
|
-
});
|
291
|
+
const dbIndexers = await getAllIndexers();
|
149
292
|
const inConfigButNotInDb = torznab.filter((configIndexer) => !dbIndexers.some((dbIndexer) => dbIndexer.url === sanitizeUrl(configIndexer)));
|
150
293
|
const inDbButNotInConfig = dbIndexers.filter((dbIndexer) => !torznab.some((configIndexer) => sanitizeUrl(configIndexer) === dbIndexer.url));
|
151
294
|
const apikeyUpdates = dbIndexers.reduce((acc, dbIndexer) => {
|
@@ -186,8 +329,8 @@ export async function syncWithDb() {
|
|
186
329
|
.update({ status: IndexerStatus.OK });
|
187
330
|
});
|
188
331
|
}
|
189
|
-
function assembleUrl(
|
190
|
-
const url = new URL(
|
332
|
+
export function assembleUrl(baseUrl, apikey, params) {
|
333
|
+
const url = new URL(baseUrl);
|
191
334
|
const searchParams = new URLSearchParams();
|
192
335
|
searchParams.set("apikey", apikey);
|
193
336
|
for (const [key, value] of Object.entries(params)) {
|
@@ -200,18 +343,18 @@ function assembleUrl(urlStr, apikey, params) {
|
|
200
343
|
async function fetchCaps(indexer) {
|
201
344
|
let response;
|
202
345
|
try {
|
203
|
-
response = await fetch(assembleUrl(indexer.url, indexer.apikey, { t: "caps" }));
|
346
|
+
response = await fetch(assembleUrl(indexer.url, indexer.apikey, { t: "caps" }), { signal: AbortSignal.timeout(ms("10 seconds")) });
|
204
347
|
}
|
205
348
|
catch (e) {
|
206
349
|
const error = new Error(`Indexer ${indexer.url} failed to respond, check verbose logs`);
|
207
|
-
logger.error(error);
|
350
|
+
logger.error(error.message);
|
208
351
|
logger.debug(e);
|
209
352
|
throw error;
|
210
353
|
}
|
211
354
|
const responseText = await response.text();
|
212
355
|
if (!response.ok) {
|
213
356
|
const error = new Error(`Indexer ${indexer.url} responded with code ${response.status} when fetching caps, check verbose logs`);
|
214
|
-
logger.error(error);
|
357
|
+
logger.error(error.message);
|
215
358
|
logger.debug(`Response body first 1000 characters: ${responseText.substring(0, 1000)}`);
|
216
359
|
throw error;
|
217
360
|
}
|
@@ -221,7 +364,7 @@ async function fetchCaps(indexer) {
|
|
221
364
|
}
|
222
365
|
catch (_) {
|
223
366
|
const error = new Error(`Indexer ${indexer.url} responded with invalid XML when fetching caps, check verbose logs`);
|
224
|
-
logger.error(error);
|
367
|
+
logger.error(error.message);
|
225
368
|
logger.debug(`Response body first 1000 characters: ${responseText.substring(0, 1000)}`);
|
226
369
|
throw error;
|
227
370
|
}
|
@@ -237,15 +380,12 @@ function collateOutcomes(correlators, outcomes) {
|
|
237
380
|
return { rejected, fulfilled };
|
238
381
|
}, { rejected: [], fulfilled: [] });
|
239
382
|
}
|
240
|
-
async function updateCaps(
|
383
|
+
export async function updateCaps() {
|
384
|
+
const indexers = await getAllIndexers();
|
241
385
|
const outcomes = await Promise.allSettled(indexers.map((indexer) => fetchCaps(indexer)));
|
242
386
|
const { fulfilled } = collateOutcomes(indexers.map((i) => i.id), outcomes);
|
243
387
|
for (const [indexerId, caps] of fulfilled) {
|
244
|
-
await
|
245
|
-
search_cap: caps.search,
|
246
|
-
tv_search_cap: caps.tvSearch,
|
247
|
-
movie_search_cap: caps.movieSearch,
|
248
|
-
});
|
388
|
+
await updateIndexerCapsById(indexerId, caps);
|
249
389
|
}
|
250
390
|
}
|
251
391
|
export async function validateTorznabUrls() {
|
@@ -262,16 +402,7 @@ export async function validateTorznabUrls() {
|
|
262
402
|
}
|
263
403
|
}
|
264
404
|
await syncWithDb();
|
265
|
-
|
266
|
-
.where({
|
267
|
-
active: true,
|
268
|
-
search_cap: null,
|
269
|
-
tv_search_cap: null,
|
270
|
-
movie_search_cap: null,
|
271
|
-
})
|
272
|
-
.orWhere({ search_cap: false, active: true })
|
273
|
-
.select({ id: "id", url: "url", apikey: "apikey" });
|
274
|
-
await updateCaps(enabledIndexersWithoutCaps);
|
405
|
+
await updateCaps();
|
275
406
|
const indexersWithoutSearch = await db("indexer")
|
276
407
|
.where({ search_cap: false, active: true })
|
277
408
|
.select({ id: "id", url: "url" });
|
@@ -283,44 +414,56 @@ export async function validateTorznabUrls() {
|
|
283
414
|
logger.warn("no working indexers available");
|
284
415
|
}
|
285
416
|
}
|
286
|
-
|
417
|
+
/**
|
418
|
+
* Snooze indexers based on the response headers and status code.
|
419
|
+
* specifically for a search, probably not applicable to a caps fetch.
|
420
|
+
*/
|
421
|
+
async function onResponseNotOk(response, indexerId) {
|
422
|
+
const retryAfterSeconds = Number(response.headers.get("Retry-After"));
|
423
|
+
const retryAfter = !Number.isNaN(retryAfterSeconds)
|
424
|
+
? Date.now() + ms(`${retryAfterSeconds} seconds`)
|
425
|
+
: response.status === 429
|
426
|
+
? Date.now() + ms("1 hour")
|
427
|
+
: Date.now() + ms("10 minutes");
|
428
|
+
await updateIndexerStatus(response.status === 429
|
429
|
+
? IndexerStatus.RATE_LIMITED
|
430
|
+
: IndexerStatus.UNKNOWN_ERROR, retryAfter, [indexerId]);
|
431
|
+
}
|
432
|
+
async function makeRequest(request) {
|
287
433
|
const { searchTimeout } = getRuntimeConfig();
|
288
|
-
const
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
}
|
298
|
-
const outcomes = await Promise.allSettled(searchUrls.map((url, i) => fetch(url, {
|
434
|
+
const url = assembleUrl(request.baseUrl, request.apikey, request.query);
|
435
|
+
const abortSignal = typeof searchTimeout === "number"
|
436
|
+
? AbortSignal.timeout(searchTimeout)
|
437
|
+
: undefined;
|
438
|
+
logger.verbose({
|
439
|
+
label: Label.TORZNAB,
|
440
|
+
message: `Querying indexer ${request.indexerId} at ${request.baseUrl} with ${inspect(request.query)}`,
|
441
|
+
});
|
442
|
+
const response = await fetch(url, {
|
299
443
|
headers: { "User-Agent": USER_AGENT },
|
300
|
-
signal:
|
301
|
-
})
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
}
|
321
|
-
|
322
|
-
|
323
|
-
const { rejected, fulfilled } = collateOutcomes(indexers.map((indexer) => indexer.id), outcomes);
|
444
|
+
signal: abortSignal,
|
445
|
+
});
|
446
|
+
if (!response.ok) {
|
447
|
+
await onResponseNotOk(response, request.indexerId);
|
448
|
+
throw new Error(`request failed with code: ${response.status}`);
|
449
|
+
}
|
450
|
+
const xml = await response.text();
|
451
|
+
const torznabResults = await xml2js.parseStringPromise(xml);
|
452
|
+
return parseTorznabResults(torznabResults);
|
453
|
+
}
|
454
|
+
async function makeRequests(indexers, getQueriesForIndexer) {
|
455
|
+
const requests = [];
|
456
|
+
for (const indexer of indexers) {
|
457
|
+
const queries = await getQueriesForIndexer(indexer);
|
458
|
+
requests.push(...queries.map((query) => ({
|
459
|
+
indexerId: indexer.id,
|
460
|
+
baseUrl: indexer.url,
|
461
|
+
apikey: indexer.apikey,
|
462
|
+
query,
|
463
|
+
})));
|
464
|
+
}
|
465
|
+
const outcomes = await Promise.allSettled(requests.map(makeRequest));
|
466
|
+
const { rejected, fulfilled } = collateOutcomes(requests.map((request) => request.indexerId), outcomes);
|
324
467
|
for (const [indexerId, reason] of rejected) {
|
325
468
|
logger.warn(`Failed to reach ${indexers.find((i) => i.id === indexerId).url}`);
|
326
469
|
logger.debug(reason);
|
@@ -330,4 +473,89 @@ async function makeRequests(indexers, getQueries) {
|
|
330
473
|
candidates: results,
|
331
474
|
}));
|
332
475
|
}
|
476
|
+
async function getAndLogIndexers(searchee, cachedSearch, mediaType, progress) {
|
477
|
+
const { excludeRecentSearch, excludeOlder } = getRuntimeConfig();
|
478
|
+
const searcheeLog = getLogString(searchee, chalk.bold.white);
|
479
|
+
const mediaTypeLog = chalk.white(mediaType.toUpperCase());
|
480
|
+
const enabledIndexers = await getEnabledIndexers();
|
481
|
+
// search history for name across all indexers
|
482
|
+
const name = searchee.title;
|
483
|
+
const timestampDataSql = await db("searchee")
|
484
|
+
.join("timestamp", "searchee.id", "timestamp.searchee_id")
|
485
|
+
.join("indexer", "timestamp.indexer_id", "indexer.id")
|
486
|
+
.whereIn("indexer.id", enabledIndexers.map((i) => i.id))
|
487
|
+
.andWhere({ name })
|
488
|
+
.select({
|
489
|
+
indexerId: "indexer.id",
|
490
|
+
firstSearched: "timestamp.first_searched",
|
491
|
+
lastSearched: "timestamp.last_searched",
|
492
|
+
});
|
493
|
+
const skipBefore = searchee.label !== Label.WEBHOOK && excludeOlder
|
494
|
+
? nMsAgo(excludeOlder)
|
495
|
+
: Number.NEGATIVE_INFINITY;
|
496
|
+
const skipAfter = searchee.label !== Label.WEBHOOK && excludeRecentSearch
|
497
|
+
? nMsAgo(excludeRecentSearch)
|
498
|
+
: Number.POSITIVE_INFINITY;
|
499
|
+
const timeFilteredIndexers = enabledIndexers.filter((indexer) => {
|
500
|
+
const entry = timestampDataSql.find((entry) => entry.indexerId === indexer.id);
|
501
|
+
if (!entry)
|
502
|
+
return true;
|
503
|
+
if (entry.firstSearched < skipBefore)
|
504
|
+
return false;
|
505
|
+
if (entry.lastSearched > skipAfter)
|
506
|
+
return false;
|
507
|
+
return true;
|
508
|
+
});
|
509
|
+
const indexersToUse = timeFilteredIndexers.filter((indexer) => {
|
510
|
+
return indexerDoesSupportMediaType(mediaType, indexer.categories);
|
511
|
+
});
|
512
|
+
// Invalidate cache if searchStr or ids is different
|
513
|
+
let shouldScanArr = true;
|
514
|
+
let parsedMedia;
|
515
|
+
const searchStr = await getSearchString(searchee);
|
516
|
+
if (cachedSearch.q === searchStr) {
|
517
|
+
shouldScanArr = false;
|
518
|
+
const res = await scanAllArrsForMedia(name, mediaType);
|
519
|
+
parsedMedia = res.isOk() ? res.unwrap() : undefined;
|
520
|
+
const ids = parsedMedia?.movie ?? parsedMedia?.series;
|
521
|
+
if (!arrIdsEqual(ids, cachedSearch.ids)) {
|
522
|
+
cachedSearch.indexerCandidates.length = 0;
|
523
|
+
cachedSearch.ids = ids;
|
524
|
+
}
|
525
|
+
}
|
526
|
+
else {
|
527
|
+
cachedSearch.q = searchStr;
|
528
|
+
cachedSearch.indexerCandidates.length = 0;
|
529
|
+
cachedSearch.ids = undefined; // Don't prematurely get ids if skipping
|
530
|
+
}
|
531
|
+
const indexersToSearch = indexersToUse.filter((indexer) => {
|
532
|
+
return !cachedSearch.indexerCandidates.some((candidates) => candidates.indexerId === indexer.id);
|
533
|
+
});
|
534
|
+
const filteringCauses = [
|
535
|
+
enabledIndexers.length > timeFilteredIndexers.length && "timestamps",
|
536
|
+
timeFilteredIndexers.length > indexersToUse.length && "category",
|
537
|
+
].filter(isTruthy);
|
538
|
+
const reasonStr = filteringCauses.length
|
539
|
+
? ` (filtered by ${formatAsList(filteringCauses, { sort: true })})`
|
540
|
+
: "";
|
541
|
+
if (!indexersToSearch.length && !cachedSearch.indexerCandidates.length) {
|
542
|
+
cachedSearch.q = null; // Won't scan arrs for multiple skips in a row
|
543
|
+
logger.info({
|
544
|
+
label: searchee.label,
|
545
|
+
message: `${progress}Skipped searching on indexers for ${searcheeLog}${reasonStr} | MediaType: ${mediaTypeLog} | IDs: N/A`,
|
546
|
+
});
|
547
|
+
return { indexersToSearch };
|
548
|
+
}
|
549
|
+
if (shouldScanArr) {
|
550
|
+
const res = await scanAllArrsForMedia(name, mediaType);
|
551
|
+
parsedMedia = res.isOk() ? res.unwrap() : undefined;
|
552
|
+
cachedSearch.ids = parsedMedia?.movie ?? parsedMedia?.series;
|
553
|
+
}
|
554
|
+
const idsStr = cachedSearch.ids ? formatFoundIds(cachedSearch.ids) : "NONE";
|
555
|
+
logger.info({
|
556
|
+
label: searchee.label,
|
557
|
+
message: `${progress}Searching for ${searcheeLog} | MediaType: ${mediaTypeLog} | IDs: ${idsStr}`,
|
558
|
+
});
|
559
|
+
return { indexersToSearch, parsedMedia };
|
560
|
+
}
|
333
561
|
//# sourceMappingURL=torznab.js.map
|