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.
Files changed (75) hide show
  1. package/dist/Result.js +17 -11
  2. package/dist/Result.js.map +1 -1
  3. package/dist/action.js +188 -74
  4. package/dist/action.js.map +1 -1
  5. package/dist/arr.js +197 -0
  6. package/dist/arr.js.map +1 -0
  7. package/dist/clients/Deluge.js +78 -55
  8. package/dist/clients/Deluge.js.map +1 -1
  9. package/dist/clients/QBittorrent.js +98 -67
  10. package/dist/clients/QBittorrent.js.map +1 -1
  11. package/dist/clients/RTorrent.js +39 -12
  12. package/dist/clients/RTorrent.js.map +1 -1
  13. package/dist/clients/TorrentClient.js +1 -1
  14. package/dist/clients/TorrentClient.js.map +1 -1
  15. package/dist/clients/Transmission.js +31 -11
  16. package/dist/clients/Transmission.js.map +1 -1
  17. package/dist/cmd.js +37 -23
  18. package/dist/cmd.js.map +1 -1
  19. package/dist/config.template.cjs +88 -52
  20. package/dist/config.template.cjs.map +1 -1
  21. package/dist/configSchema.js +102 -14
  22. package/dist/configSchema.js.map +1 -1
  23. package/dist/configuration.js +4 -1
  24. package/dist/configuration.js.map +1 -1
  25. package/dist/constants.js +114 -6
  26. package/dist/constants.js.map +1 -1
  27. package/dist/dataFiles.js +4 -5
  28. package/dist/dataFiles.js.map +1 -1
  29. package/dist/db.js +2 -1
  30. package/dist/db.js.map +1 -1
  31. package/dist/decide.js +282 -167
  32. package/dist/decide.js.map +1 -1
  33. package/dist/diff.js +13 -3
  34. package/dist/diff.js.map +1 -1
  35. package/dist/errors.js +5 -2
  36. package/dist/errors.js.map +1 -1
  37. package/dist/indexers.js +96 -16
  38. package/dist/indexers.js.map +1 -1
  39. package/dist/inject.js +410 -0
  40. package/dist/inject.js.map +1 -0
  41. package/dist/jobs.js +9 -2
  42. package/dist/jobs.js.map +1 -1
  43. package/dist/logger.js +29 -9
  44. package/dist/logger.js.map +1 -1
  45. package/dist/migrations/00-initialSchema.js.map +1 -1
  46. package/dist/migrations/05-caps.js +16 -0
  47. package/dist/migrations/05-caps.js.map +1 -0
  48. package/dist/migrations/06-uniqueDecisions.js +29 -0
  49. package/dist/migrations/06-uniqueDecisions.js.map +1 -0
  50. package/dist/migrations/07-limits.js +12 -0
  51. package/dist/migrations/07-limits.js.map +1 -0
  52. package/dist/migrations/migrations.js +13 -1
  53. package/dist/migrations/migrations.js.map +1 -1
  54. package/dist/parseTorrent.js +6 -0
  55. package/dist/parseTorrent.js.map +1 -1
  56. package/dist/pipeline.js +225 -116
  57. package/dist/pipeline.js.map +1 -1
  58. package/dist/preFilter.js +130 -52
  59. package/dist/preFilter.js.map +1 -1
  60. package/dist/pushNotifier.js +7 -5
  61. package/dist/pushNotifier.js.map +1 -1
  62. package/dist/runtimeConfig.js.map +1 -1
  63. package/dist/searchee.js +200 -19
  64. package/dist/searchee.js.map +1 -1
  65. package/dist/server.js +79 -32
  66. package/dist/server.js.map +1 -1
  67. package/dist/startup.js +17 -6
  68. package/dist/startup.js.map +1 -1
  69. package/dist/torrent.js +117 -51
  70. package/dist/torrent.js.map +1 -1
  71. package/dist/torznab.js +374 -146
  72. package/dist/torznab.js.map +1 -1
  73. package/dist/utils.js +252 -33
  74. package/dist/utils.js.map +1 -1
  75. 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 { EP_REGEX, SEASON_REGEX, USER_AGENT } from "./constants.js";
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 { hasVideo } from "./searchee.js";
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
- "Unknown tracker",
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 capsSection = xml?.caps?.searching?.[0];
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(capsSection?.search)),
42
- tvSearch: Boolean(isAvailable(capsSection?.["tv-search"])),
43
- movieSearch: Boolean(isAvailable(capsSection?.["movie-search"])),
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 nameWithoutExtension = stripExtension(searchee.name);
48
- const isVideo = hasVideo(searchee);
49
- const extractNumber = (str) => parseInt(str.match(/\d+/)[0]);
50
- const mediaType = getTag(nameWithoutExtension, isVideo);
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 = nameWithoutExtension.match(EP_REGEX);
97
+ const match = stem.match(EP_REGEX);
98
+ const groups = match.groups;
53
99
  return [
54
100
  {
55
101
  t: "tvsearch",
56
- q: cleanseSeparators(match.groups.title),
57
- season: match.groups.season
58
- ? extractNumber(match.groups.season)
59
- : match.groups.year,
60
- ep: match.groups.episode
61
- ? extractNumber(match.groups.episode)
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 = nameWithoutExtension.match(SEASON_REGEX);
112
+ const match = stem.match(SEASON_REGEX);
113
+ const groups = match.groups;
68
114
  return [
69
115
  {
70
116
  t: "tvsearch",
71
- q: cleanseSeparators(match.groups.title),
72
- season: extractNumber(match.groups.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
- const animeQueries = getAnimeQueries(nameWithoutExtension);
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: reformatTitleForSearching(nameWithoutExtension),
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 queryRssFeeds() {
93
- const candidatesByUrl = await makeRequests(await getEnabledIndexers(), () => [{ t: "search", q: "" }]);
94
- return candidatesByUrl.flatMap((e) => e.candidates);
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
- export async function searchTorznab(searchee) {
97
- const { excludeRecentSearch, excludeOlder, torznab } = getRuntimeConfig();
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 enabledIndexers = await getEnabledIndexers();
102
- const name = searchee.name;
103
- // search history for name across all indexers
104
- const timestampDataSql = await db("searchee")
105
- .join("timestamp", "searchee.id", "timestamp.searchee_id")
106
- .join("indexer", "timestamp.indexer_id", "indexer.id")
107
- .whereIn("indexer.id", enabledIndexers.map((i) => i.id))
108
- .andWhere({ name })
109
- .select({
110
- indexerId: "indexer.id",
111
- firstSearched: "timestamp.first_searched",
112
- lastSearched: "timestamp.last_searched",
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
- const indexersToUse = enabledIndexers.filter((indexer) => {
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 db("indexer")
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(urlStr, apikey, params) {
190
- const url = new URL(urlStr);
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(indexers) {
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 db("indexer").where({ id: indexerId }).update({
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
- const enabledIndexersWithoutCaps = await db("indexer")
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
- async function makeRequests(indexers, getQueries) {
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 searchUrls = indexers.flatMap((indexer) => getQueries(indexer).map((query) => assembleUrl(indexer.url, indexer.apikey, query)));
289
- searchUrls.forEach((message) => void logger.verbose({ label: Label.TORZNAB, message }));
290
- const abortControllers = searchUrls.map(() => new AbortController());
291
- if (typeof searchTimeout === "number") {
292
- setTimeout(() => {
293
- for (const abortController of abortControllers) {
294
- abortController.abort();
295
- }
296
- }, searchTimeout).unref();
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: abortControllers[i].signal,
301
- })
302
- .then((response) => {
303
- if (!response.ok) {
304
- const retryAfterSeconds = Number(response.headers.get("Retry-After"));
305
- if (!Number.isNaN(retryAfterSeconds)) {
306
- updateIndexerStatus(response.status === 429
307
- ? IndexerStatus.RATE_LIMITED
308
- : IndexerStatus.UNKNOWN_ERROR, Date.now() + ms(`${retryAfterSeconds} seconds`), [indexers[i].id]);
309
- }
310
- else {
311
- updateIndexerStatus(response.status === 429
312
- ? IndexerStatus.RATE_LIMITED
313
- : IndexerStatus.UNKNOWN_ERROR, response.status === 429
314
- ? Date.now() + ms("1 hour")
315
- : Date.now() + ms("10 minutes"), [indexers[i].id]);
316
- }
317
- throw new Error(`request failed with code: ${response.status}`);
318
- }
319
- return response.text();
320
- })
321
- .then(xml2js.parseStringPromise)
322
- .then(parseTorznabResults)));
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