cross-seed 7.0.0-1 → 7.0.0-2

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 (229) hide show
  1. package/dist/Result.d.ts +27 -0
  2. package/dist/Result.js +64 -0
  3. package/dist/Result.js.map +1 -0
  4. package/dist/action.d.ts +34 -0
  5. package/dist/action.js +694 -0
  6. package/dist/action.js.map +1 -0
  7. package/dist/arr.d.ts +31 -0
  8. package/dist/arr.js +267 -0
  9. package/dist/arr.js.map +1 -0
  10. package/dist/auth.d.ts +3 -0
  11. package/dist/auth.js +28 -0
  12. package/dist/auth.js.map +1 -0
  13. package/dist/clients/Deluge.d.ts +153 -0
  14. package/dist/clients/Deluge.js +698 -0
  15. package/dist/clients/Deluge.js.map +1 -0
  16. package/dist/clients/QBittorrent.d.ts +218 -0
  17. package/dist/clients/QBittorrent.js +785 -0
  18. package/dist/clients/QBittorrent.js.map +1 -0
  19. package/dist/clients/RTorrent.d.ts +43 -0
  20. package/dist/clients/RTorrent.js +657 -0
  21. package/dist/clients/RTorrent.js.map +1 -0
  22. package/dist/clients/TorrentClient.d.ts +108 -0
  23. package/dist/clients/TorrentClient.js +341 -0
  24. package/dist/clients/TorrentClient.js.map +1 -0
  25. package/dist/clients/Transmission.d.ts +43 -0
  26. package/dist/clients/Transmission.js +404 -0
  27. package/dist/clients/Transmission.js.map +1 -0
  28. package/dist/cmd.d.ts +2 -0
  29. package/dist/cmd.js +128 -0
  30. package/dist/cmd.js.map +1 -0
  31. package/dist/configSchema.d.ts +1 -0
  32. package/dist/configSchema.js +2 -0
  33. package/dist/configSchema.js.map +1 -0
  34. package/dist/configuration.d.ts +63 -0
  35. package/dist/configuration.js +321 -0
  36. package/dist/configuration.js.map +1 -0
  37. package/dist/constants.d.ts +108 -0
  38. package/dist/constants.js +251 -0
  39. package/dist/constants.js.map +1 -0
  40. package/dist/dataFiles.d.ts +8 -0
  41. package/dist/dataFiles.js +223 -0
  42. package/dist/dataFiles.js.map +1 -0
  43. package/dist/db.d.ts +3 -0
  44. package/dist/db.js +216 -0
  45. package/dist/db.js.map +1 -0
  46. package/dist/dbConfig.d.ts +4 -0
  47. package/dist/dbConfig.js +67 -0
  48. package/dist/dbConfig.js.map +1 -0
  49. package/dist/decide.d.ts +25 -0
  50. package/dist/decide.js +553 -0
  51. package/dist/decide.js.map +1 -0
  52. package/dist/diff.d.ts +1 -0
  53. package/dist/diff.js +24 -0
  54. package/dist/diff.js.map +1 -0
  55. package/dist/errors.d.ts +3 -0
  56. package/dist/errors.js +7 -0
  57. package/dist/errors.js.map +1 -0
  58. package/dist/indexers.d.ts +105 -0
  59. package/dist/indexers.js +248 -0
  60. package/dist/indexers.js.map +1 -0
  61. package/dist/inject.d.ts +2 -0
  62. package/dist/inject.js +594 -0
  63. package/dist/inject.js.map +1 -0
  64. package/dist/jobs.d.ts +29 -0
  65. package/dist/jobs.js +151 -0
  66. package/dist/jobs.js.map +1 -0
  67. package/dist/logger.d.ts +29 -0
  68. package/dist/logger.js +157 -0
  69. package/dist/logger.js.map +1 -0
  70. package/dist/migrations/00-initialSchema.d.ts +9 -0
  71. package/dist/migrations/00-initialSchema.js +30 -0
  72. package/dist/migrations/00-initialSchema.js.map +1 -0
  73. package/dist/migrations/01-jobs.d.ts +9 -0
  74. package/dist/migrations/01-jobs.js +12 -0
  75. package/dist/migrations/01-jobs.js.map +1 -0
  76. package/dist/migrations/02-timestamps.d.ts +9 -0
  77. package/dist/migrations/02-timestamps.js +21 -0
  78. package/dist/migrations/02-timestamps.js.map +1 -0
  79. package/dist/migrations/03-rateLimits.d.ts +9 -0
  80. package/dist/migrations/03-rateLimits.js +14 -0
  81. package/dist/migrations/03-rateLimits.js.map +1 -0
  82. package/dist/migrations/04-auth.d.ts +9 -0
  83. package/dist/migrations/04-auth.js +13 -0
  84. package/dist/migrations/04-auth.js.map +1 -0
  85. package/dist/migrations/05-caps.d.ts +9 -0
  86. package/dist/migrations/05-caps.js +16 -0
  87. package/dist/migrations/05-caps.js.map +1 -0
  88. package/dist/migrations/06-uniqueDecisions.d.ts +9 -0
  89. package/dist/migrations/06-uniqueDecisions.js +29 -0
  90. package/dist/migrations/06-uniqueDecisions.js.map +1 -0
  91. package/dist/migrations/07-limits.d.ts +9 -0
  92. package/dist/migrations/07-limits.js +12 -0
  93. package/dist/migrations/07-limits.js.map +1 -0
  94. package/dist/migrations/08-rss.d.ts +9 -0
  95. package/dist/migrations/08-rss.js +15 -0
  96. package/dist/migrations/08-rss.js.map +1 -0
  97. package/dist/migrations/09-clientAndDataSearchees.d.ts +9 -0
  98. package/dist/migrations/09-clientAndDataSearchees.js +34 -0
  99. package/dist/migrations/09-clientAndDataSearchees.js.map +1 -0
  100. package/dist/migrations/10-indexerNameAudioBookCaps.d.ts +9 -0
  101. package/dist/migrations/10-indexerNameAudioBookCaps.js +18 -0
  102. package/dist/migrations/10-indexerNameAudioBookCaps.js.map +1 -0
  103. package/dist/migrations/11-trackers.d.ts +9 -0
  104. package/dist/migrations/11-trackers.js +38 -0
  105. package/dist/migrations/11-trackers.js.map +1 -0
  106. package/dist/migrations/12-user-auth.d.ts +9 -0
  107. package/dist/migrations/12-user-auth.js +22 -0
  108. package/dist/migrations/12-user-auth.js.map +1 -0
  109. package/dist/migrations/13-settings.d.ts +9 -0
  110. package/dist/migrations/13-settings.js +23 -0
  111. package/dist/migrations/13-settings.js.map +1 -0
  112. package/dist/migrations/14-indexer-enabled-flag.d.ts +9 -0
  113. package/dist/migrations/14-indexer-enabled-flag.js +12 -0
  114. package/dist/migrations/14-indexer-enabled-flag.js.map +1 -0
  115. package/dist/migrations/15-remove-url-unique-constraint.d.ts +9 -0
  116. package/dist/migrations/15-remove-url-unique-constraint.js +14 -0
  117. package/dist/migrations/15-remove-url-unique-constraint.js.map +1 -0
  118. package/dist/migrations/16-prune-inactive-indexers.d.ts +9 -0
  119. package/dist/migrations/16-prune-inactive-indexers.js +17 -0
  120. package/dist/migrations/16-prune-inactive-indexers.js.map +1 -0
  121. package/dist/migrations/migrations.d.ts +13 -0
  122. package/dist/migrations/migrations.js +41 -0
  123. package/dist/migrations/migrations.js.map +1 -0
  124. package/dist/parseTorrent.d.ts +53 -0
  125. package/dist/parseTorrent.js +128 -0
  126. package/dist/parseTorrent.js.map +1 -0
  127. package/dist/pipeline.d.ts +41 -0
  128. package/dist/pipeline.js +574 -0
  129. package/dist/pipeline.js.map +1 -0
  130. package/dist/preFilter.d.ts +25 -0
  131. package/dist/preFilter.js +250 -0
  132. package/dist/preFilter.js.map +1 -0
  133. package/dist/problems/linking.d.ts +2 -0
  134. package/dist/problems/linking.js +80 -0
  135. package/dist/problems/linking.js.map +1 -0
  136. package/dist/problems/path.d.ts +22 -0
  137. package/dist/problems/path.js +96 -0
  138. package/dist/problems/path.js.map +1 -0
  139. package/dist/problems.d.ts +13 -0
  140. package/dist/problems.js +48 -0
  141. package/dist/problems.js.map +1 -0
  142. package/dist/pushNotifier.d.ts +19 -0
  143. package/dist/pushNotifier.js +137 -0
  144. package/dist/pushNotifier.js.map +1 -0
  145. package/dist/routes/baseApi.d.ts +2 -0
  146. package/dist/routes/baseApi.js +354 -0
  147. package/dist/routes/baseApi.js.map +1 -0
  148. package/dist/routes/indexerApi.d.ts +6 -0
  149. package/dist/routes/indexerApi.js +165 -0
  150. package/dist/routes/indexerApi.js.map +1 -0
  151. package/dist/routes/staticFrontendPlugin.d.ts +4 -0
  152. package/dist/routes/staticFrontendPlugin.js +61 -0
  153. package/dist/routes/staticFrontendPlugin.js.map +1 -0
  154. package/dist/runtimeConfig.d.ts +6 -0
  155. package/dist/runtimeConfig.js +27 -0
  156. package/dist/runtimeConfig.js.map +1 -0
  157. package/dist/searchee.d.ts +108 -0
  158. package/dist/searchee.js +689 -0
  159. package/dist/searchee.js.map +1 -0
  160. package/dist/server.d.ts +4 -0
  161. package/dist/server.js +65 -0
  162. package/dist/server.js.map +1 -0
  163. package/dist/services/indexerService.d.ts +96 -0
  164. package/dist/services/indexerService.js +287 -0
  165. package/dist/services/indexerService.js.map +1 -0
  166. package/dist/sessionCookies.d.ts +5 -0
  167. package/dist/sessionCookies.js +27 -0
  168. package/dist/sessionCookies.js.map +1 -0
  169. package/dist/startup.d.ts +25 -0
  170. package/dist/startup.js +157 -0
  171. package/dist/startup.js.map +1 -0
  172. package/dist/torrent.d.ts +69 -0
  173. package/dist/torrent.js +641 -0
  174. package/dist/torrent.js.map +1 -0
  175. package/dist/torznab.d.ts +60 -0
  176. package/dist/torznab.js +711 -0
  177. package/dist/torznab.js.map +1 -0
  178. package/dist/trpc/fastifyAdapter.d.ts +2 -0
  179. package/dist/trpc/fastifyAdapter.js +9 -0
  180. package/dist/trpc/fastifyAdapter.js.map +1 -0
  181. package/dist/trpc/index.d.ts +49 -0
  182. package/dist/trpc/index.js +53 -0
  183. package/dist/trpc/index.js.map +1 -0
  184. package/dist/trpc/routers/auth.d.ts +43 -0
  185. package/dist/trpc/routers/auth.js +116 -0
  186. package/dist/trpc/routers/auth.js.map +1 -0
  187. package/dist/trpc/routers/clients.d.ts +21 -0
  188. package/dist/trpc/routers/clients.js +65 -0
  189. package/dist/trpc/routers/clients.js.map +1 -0
  190. package/dist/trpc/routers/health.d.ts +14 -0
  191. package/dist/trpc/routers/health.js +20 -0
  192. package/dist/trpc/routers/health.js.map +1 -0
  193. package/dist/trpc/routers/index.d.ts +391 -0
  194. package/dist/trpc/routers/index.js +23 -0
  195. package/dist/trpc/routers/index.js.map +1 -0
  196. package/dist/trpc/routers/indexers.d.ts +75 -0
  197. package/dist/trpc/routers/indexers.js +79 -0
  198. package/dist/trpc/routers/indexers.js.map +1 -0
  199. package/dist/trpc/routers/jobs.d.ts +33 -0
  200. package/dist/trpc/routers/jobs.js +84 -0
  201. package/dist/trpc/routers/jobs.js.map +1 -0
  202. package/dist/trpc/routers/logs.d.ts +27 -0
  203. package/dist/trpc/routers/logs.js +91 -0
  204. package/dist/trpc/routers/logs.js.map +1 -0
  205. package/dist/trpc/routers/searchees.d.ts +51 -0
  206. package/dist/trpc/routers/searchees.js +156 -0
  207. package/dist/trpc/routers/searchees.js.map +1 -0
  208. package/dist/trpc/routers/settings.d.ts +83 -0
  209. package/dist/trpc/routers/settings.js +92 -0
  210. package/dist/trpc/routers/settings.js.map +1 -0
  211. package/dist/trpc/routers/stats.d.ts +42 -0
  212. package/dist/trpc/routers/stats.js +102 -0
  213. package/dist/trpc/routers/stats.js.map +1 -0
  214. package/dist/userAuth.d.ts +21 -0
  215. package/dist/userAuth.js +86 -0
  216. package/dist/userAuth.js.map +1 -0
  217. package/dist/utils/authUtils.d.ts +10 -0
  218. package/dist/utils/authUtils.js +24 -0
  219. package/dist/utils/authUtils.js.map +1 -0
  220. package/dist/utils/logWatcher.d.ts +28 -0
  221. package/dist/utils/logWatcher.js +218 -0
  222. package/dist/utils/logWatcher.js.map +1 -0
  223. package/dist/utils/object.d.ts +1 -0
  224. package/dist/utils/object.js +4 -0
  225. package/dist/utils/object.js.map +1 -0
  226. package/dist/utils.d.ts +175 -0
  227. package/dist/utils.js +660 -0
  228. package/dist/utils.js.map +1 -0
  229. package/package.json +2 -2
@@ -0,0 +1,785 @@
1
+ import { readdir } from "fs/promises";
2
+ import ms from "ms";
3
+ import path from "path";
4
+ import { ABS_WIN_PATH_REGEX, InjectionResult, TORRENT_CATEGORY_SUFFIX, TORRENT_TAG, USER_AGENT, } from "../constants.js";
5
+ import { db } from "../db.js";
6
+ import { CrossSeedError } from "../errors.js";
7
+ import { Label, logger } from "../logger.js";
8
+ import { resultOf, resultOfErr } from "../Result.js";
9
+ import { getRuntimeConfig } from "../runtimeConfig.js";
10
+ import { createSearcheeFromDB, parseTitle, updateSearcheeClientDB, } from "../searchee.js";
11
+ import { extractCredentialsFromUrl, extractInt, getLogString, getPathParts, humanReadableSize, sanitizeInfoHash, wait, } from "../utils.js";
12
+ import { shouldResumeFromNonRelevantFiles, clientSearcheeModified, getMaxRemainingBytes, getResumeStopTime, organizeTrackers, resumeErrSleepTime, resumeSleepTime, shouldRecheck, } from "./TorrentClient.js";
13
+ const X_WWW_FORM_URLENCODED = {
14
+ "Content-Type": "application/x-www-form-urlencoded",
15
+ };
16
+ export default class QBittorrent {
17
+ cookie;
18
+ url;
19
+ version;
20
+ versionMajor;
21
+ versionMinor;
22
+ versionPatch;
23
+ clientHost;
24
+ clientPriority;
25
+ clientType = Label.QBITTORRENT;
26
+ readonly;
27
+ label;
28
+ constructor(url, clientHost, priority, readonly) {
29
+ this.clientHost = clientHost;
30
+ this.clientPriority = priority;
31
+ this.readonly = readonly;
32
+ this.label = `${this.clientType}@${this.clientHost}`;
33
+ this.url = extractCredentialsFromUrl(url, "/api/v2").unwrapOrThrow(new CrossSeedError(`[${this.label}] qBittorrent url must be percent-encoded`));
34
+ }
35
+ async login() {
36
+ let response;
37
+ const { href, username, password } = this.url;
38
+ try {
39
+ response = await fetch(`${href}/auth/login`, {
40
+ method: "POST",
41
+ body: new URLSearchParams({ username, password }),
42
+ headers: { "User-Agent": USER_AGENT },
43
+ signal: AbortSignal.timeout(ms("10 seconds")),
44
+ });
45
+ }
46
+ catch (e) {
47
+ throw new CrossSeedError(`qBittorrent login failed: ${e.message}`);
48
+ }
49
+ if (response.status !== 200) {
50
+ throw new CrossSeedError(`qBittorrent login failed with code ${response.status}`);
51
+ }
52
+ this.cookie = response.headers.getSetCookie()[0];
53
+ if (!this.cookie) {
54
+ throw new CrossSeedError(`qBittorrent login failed: Invalid username or password`);
55
+ }
56
+ const version = await this.request("/app/version", "", X_WWW_FORM_URLENCODED);
57
+ if (!version) {
58
+ throw new CrossSeedError(`qBittorrent login failed: Unable to retrieve version`);
59
+ }
60
+ this.version = version;
61
+ this.versionMajor = extractInt(this.version);
62
+ this.versionMinor = extractInt(this.version.split(".")[1]);
63
+ this.versionPatch = extractInt(this.version.split(".")[2]);
64
+ if (this.versionMajor < 4 ||
65
+ (this.versionMajor === 4 && this.versionMinor < 3) ||
66
+ (this.versionMajor === 4 &&
67
+ this.versionMinor === 3 &&
68
+ this.versionPatch < 1)) {
69
+ throw new CrossSeedError(`qBittorrent minimum supported version is v4.3.1, current version is ${this.version}`);
70
+ }
71
+ logger.info({
72
+ label: this.label,
73
+ message: `Logged in to ${this.version} successfully${this.readonly ? " (readonly)" : ""}`,
74
+ });
75
+ }
76
+ async validateConfig() {
77
+ const { torrentDir } = getRuntimeConfig();
78
+ try {
79
+ await this.login();
80
+ }
81
+ catch (e) {
82
+ e.message = `[${this.label}] ${e.message}`;
83
+ throw e;
84
+ }
85
+ await this.createTag();
86
+ if (!torrentDir)
87
+ return;
88
+ const { resume_data_storage_type } = await this.getPreferences();
89
+ if (resume_data_storage_type === "SQLite") {
90
+ throw new CrossSeedError(`[${this.label}] torrentDir is not compatible with SQLite mode in qBittorrent, use https://www.cross-seed.org/docs/basics/options#useclienttorrents`);
91
+ }
92
+ if (!(await readdir(torrentDir)).some((f) => f.endsWith(".fastresume"))) {
93
+ throw new CrossSeedError(`[${this.label}] Invalid torrentDir, if no torrents are in client set to null for now: https://www.cross-seed.org/docs/basics/options#torrentdir`);
94
+ }
95
+ }
96
+ async request(path, body, headers = {}, numRetries = 3) {
97
+ const bodyStr = body instanceof URLSearchParams || body instanceof FormData
98
+ ? JSON.stringify(Object.fromEntries(body))
99
+ : JSON.stringify(body).replace(/(?:hash(?:es)?=)([a-z0-9]{40})/i, (match, hash) => match.replace(hash, sanitizeInfoHash(hash)));
100
+ let response;
101
+ const retries = Math.max(numRetries, 0);
102
+ for (let i = 0; i <= retries; i++) {
103
+ try {
104
+ logger.verbose({
105
+ label: this.label,
106
+ message: `Making request (${retries - i}) to ${path} with body ${bodyStr}`,
107
+ });
108
+ response = await fetch(`${this.url.href}${path}`, {
109
+ method: "POST",
110
+ headers: {
111
+ Cookie: this.cookie,
112
+ "User-Agent": USER_AGENT,
113
+ ...headers,
114
+ },
115
+ body,
116
+ signal: AbortSignal.timeout(ms("10 minutes")),
117
+ });
118
+ if (response.status === 403) {
119
+ if (i >= retries) {
120
+ logger.error({
121
+ label: this.label,
122
+ message: `Received 403 from API after ${retries} retries`,
123
+ });
124
+ break;
125
+ }
126
+ logger.verbose({
127
+ label: this.label,
128
+ message: `Received 403 from API, re-authenticating and retrying (${retries - i} retries left)`,
129
+ });
130
+ await this.login();
131
+ await wait(Math.min(ms("1 second") * 2 ** i, ms("10 seconds")));
132
+ continue;
133
+ }
134
+ if (response.status >= 500 && response.status < 600) {
135
+ if (i >= retries) {
136
+ logger.error({
137
+ label: this.label,
138
+ message: `Received ${response.status} from API after ${retries} retries`,
139
+ });
140
+ break;
141
+ }
142
+ logger.verbose({
143
+ label: this.label,
144
+ message: `Received ${response.status} from API, ${retries - i} retries remaining`,
145
+ });
146
+ await wait(Math.min(ms("1 second") * 2 ** i, ms("10 seconds")));
147
+ continue;
148
+ }
149
+ break;
150
+ }
151
+ catch (e) {
152
+ if (i >= retries) {
153
+ logger.error({
154
+ label: this.label,
155
+ message: `Request failed after ${retries} retries: ${e.message}`,
156
+ });
157
+ logger.debug(e);
158
+ break;
159
+ }
160
+ logger.verbose({
161
+ label: this.label,
162
+ message: `Request failed, ${retries - i} retries remaining: ${e.message}`,
163
+ });
164
+ await wait(Math.min(ms("1 second") * 2 ** i, ms("10 seconds")));
165
+ continue;
166
+ }
167
+ }
168
+ return response?.text();
169
+ }
170
+ async getPreferences() {
171
+ const responseText = await this.request("/app/preferences", "", X_WWW_FORM_URLENCODED);
172
+ if (!responseText) {
173
+ throw new CrossSeedError(`[${this.label}] qBittorrent failed to retrieve preferences`);
174
+ }
175
+ return JSON.parse(responseText);
176
+ }
177
+ /**
178
+ * Always returns "Original" for API searchees due to isSubfolderContentLayout.
179
+ * This is not an issue since it's either a MATCH or we are linking.
180
+ * @param searchee the Searchee the match was sourced from
181
+ * @param searcheeInfo the torrent info from the searchee
182
+ * @param destinationDir the destinationDir for the new torrent
183
+ * @returns the layout to use for the new torrent
184
+ */
185
+ getLayoutForNewTorrent(searchee, searcheeInfo, destinationDir) {
186
+ return destinationDir
187
+ ? "Original"
188
+ : this.isSubfolderContentLayout(searchee, searcheeInfo)
189
+ ? "Subfolder"
190
+ : "Original";
191
+ }
192
+ async getCategoryForNewTorrent(category, savePath, autoTMM) {
193
+ const { duplicateCategories, linkCategory } = getRuntimeConfig();
194
+ if (!duplicateCategories) {
195
+ return category;
196
+ }
197
+ if (!category.length || category === linkCategory) {
198
+ return category; // Use tags for category duplication if linking
199
+ }
200
+ const dupeCategory = category.endsWith(TORRENT_CATEGORY_SUFFIX)
201
+ ? category
202
+ : `${category}${TORRENT_CATEGORY_SUFFIX}`;
203
+ if (!autoTMM)
204
+ return dupeCategory;
205
+ // savePath is guaranteed to be the base category's save path due to autoTMM
206
+ const categories = await this.getAllCategories();
207
+ const newRes = categories.find((c) => c.name === dupeCategory);
208
+ if (!newRes) {
209
+ await this.createCategory(dupeCategory, savePath);
210
+ }
211
+ else if (newRes.savePath !== savePath) {
212
+ await this.editCategory(dupeCategory, savePath);
213
+ }
214
+ return dupeCategory;
215
+ }
216
+ getTagsForNewTorrent(searcheeInfo, destinationDir) {
217
+ const { duplicateCategories, linkCategory } = getRuntimeConfig();
218
+ if (!duplicateCategories || !searcheeInfo || !destinationDir) {
219
+ return TORRENT_TAG; // Require destinationDir to duplicate category using tags
220
+ }
221
+ const searcheeCategory = searcheeInfo.category;
222
+ if (!searcheeCategory.length || searcheeCategory === linkCategory) {
223
+ return TORRENT_TAG;
224
+ }
225
+ if (searcheeCategory.endsWith(TORRENT_CATEGORY_SUFFIX)) {
226
+ return `${TORRENT_TAG},${searcheeCategory}`;
227
+ }
228
+ return `${TORRENT_TAG},${searcheeCategory}${TORRENT_CATEGORY_SUFFIX}`;
229
+ }
230
+ async createTag() {
231
+ await this.request("/torrents/createTags", `tags=${TORRENT_TAG}`, X_WWW_FORM_URLENCODED);
232
+ }
233
+ async createCategory(category, savePath) {
234
+ await this.request("/torrents/createCategory", `category=${category}&savePath=${savePath}`, X_WWW_FORM_URLENCODED);
235
+ }
236
+ async editCategory(category, savePath) {
237
+ await this.request("/torrents/editCategory", `category=${category}&savePath=${savePath}`, X_WWW_FORM_URLENCODED);
238
+ }
239
+ async getAllCategories() {
240
+ const responseText = await this.request("/torrents/categories", "");
241
+ return responseText ? Object.values(JSON.parse(responseText)) : [];
242
+ }
243
+ torrentFileToFile(torrentFile) {
244
+ return {
245
+ name: path.basename(torrentFile.name),
246
+ path: torrentFile.name,
247
+ length: torrentFile.size,
248
+ };
249
+ }
250
+ async getFiles(infoHash) {
251
+ const responseText = await this.request("/torrents/files", `hash=${infoHash}`, X_WWW_FORM_URLENCODED);
252
+ if (!responseText)
253
+ return null;
254
+ try {
255
+ const files = JSON.parse(responseText);
256
+ return files.map(this.torrentFileToFile);
257
+ }
258
+ catch (e) {
259
+ logger.debug({ label: this.label, message: e });
260
+ return null;
261
+ }
262
+ }
263
+ async getTrackers(infoHash) {
264
+ const responseText = await this.request("/torrents/trackers", `hash=${infoHash}`, X_WWW_FORM_URLENCODED);
265
+ if (!responseText)
266
+ return null;
267
+ try {
268
+ const trackers = JSON.parse(responseText);
269
+ return organizeTrackers(trackers);
270
+ }
271
+ catch (e) {
272
+ logger.debug({ label: this.label, message: e });
273
+ return null;
274
+ }
275
+ }
276
+ async addTorrent(formData) {
277
+ await this.request("/torrents/add", formData);
278
+ }
279
+ async recheckTorrent(infoHash) {
280
+ // Pause first as it may resume after recheck automatically
281
+ await this.request(`/torrents/${this.versionMajor >= 5 ? "stop" : "pause"}`, `hashes=${infoHash}`, X_WWW_FORM_URLENCODED);
282
+ await this.request("/torrents/recheck", `hashes=${infoHash}`, X_WWW_FORM_URLENCODED);
283
+ }
284
+ /*
285
+ * @param searchee the Searchee we are generating off (in client)
286
+ * @return either a string containing the path or a error mesage
287
+ */
288
+ async getDownloadDir(meta, options) {
289
+ const { torrentDir } = getRuntimeConfig();
290
+ try {
291
+ const torrentInfo = await this.getTorrentInfo(meta.infoHash);
292
+ if (!torrentInfo) {
293
+ return resultOfErr("NOT_FOUND");
294
+ }
295
+ if (torrentDir &&
296
+ this.isNoSubfolderContentLayout(meta, torrentInfo)) {
297
+ logger.error({
298
+ label: this.label,
299
+ message: `NoSubfolder content layout is not supported with torrentDir, use https://www.cross-seed.org/docs/basics/options#useclienttorrents: ${torrentInfo.name} [${sanitizeInfoHash(torrentInfo.hash)}]`,
300
+ });
301
+ return resultOfErr("INVALID_DATA");
302
+ }
303
+ if (options.onlyCompleted &&
304
+ !this.isTorrentInfoComplete(torrentInfo)) {
305
+ return resultOfErr("TORRENT_NOT_COMPLETE");
306
+ }
307
+ return resultOf(this.getCorrectSavePath(meta, torrentInfo));
308
+ }
309
+ catch (e) {
310
+ logger.debug({ label: this.label, message: e });
311
+ if (e.message.includes("retrieve")) {
312
+ return resultOfErr("NOT_FOUND");
313
+ }
314
+ return resultOfErr("UNKNOWN_ERROR");
315
+ }
316
+ }
317
+ /*
318
+ * @param metas the Searchees we are generating off (in client)
319
+ * @return a map of infohash to path
320
+ */
321
+ async getAllDownloadDirs(options) {
322
+ const { torrentDir } = getRuntimeConfig();
323
+ const torrents = await this.getAllTorrentInfo();
324
+ const torrentSavePaths = new Map();
325
+ const infoHashMetaMap = options.metas.reduce((acc, meta) => {
326
+ acc.set(meta.infoHash, meta);
327
+ return acc;
328
+ }, new Map());
329
+ for (const torrent of torrents) {
330
+ const meta = infoHashMetaMap.get(torrent.hash) ||
331
+ (torrent.infohash_v2 &&
332
+ infoHashMetaMap.get(torrent.infohash_v2)) ||
333
+ (torrent.infohash_v1 &&
334
+ infoHashMetaMap.get(torrent.infohash_v1)) ||
335
+ undefined;
336
+ if (torrentDir &&
337
+ meta &&
338
+ this.isNoSubfolderContentLayout(meta, torrent)) {
339
+ throw new CrossSeedError(`[${this.label}] NoSubfolder content layout is not supported with torrentDir, use https://www.cross-seed.org/docs/basics/options#useclienttorrents: ${torrent.name} [${sanitizeInfoHash(torrent.hash)}]`);
340
+ }
341
+ if (options.onlyCompleted && !this.isTorrentInfoComplete(torrent)) {
342
+ continue;
343
+ }
344
+ const savePath = meta
345
+ ? this.getCorrectSavePath(meta, torrent)
346
+ : torrent.save_path;
347
+ if (torrent.infohash_v1?.length) {
348
+ torrentSavePaths.set(torrent.infohash_v1, savePath);
349
+ }
350
+ if (options.v1HashOnly)
351
+ continue;
352
+ torrentSavePaths.set(torrent.hash, savePath);
353
+ if (torrent.infohash_v2?.length) {
354
+ torrentSavePaths.set(torrent.infohash_v2, savePath);
355
+ }
356
+ }
357
+ return torrentSavePaths;
358
+ }
359
+ /*
360
+ * @param searchee the Searchee we are generating off (in client)
361
+ * @param torrentInfo the torrent info from the searchee
362
+ * @return string absolute location from client with content layout considered
363
+ */
364
+ getCorrectSavePath(data, torrentInfo) {
365
+ const subfolderContentLayout = this.isSubfolderContentLayout(data, torrentInfo);
366
+ if (subfolderContentLayout) {
367
+ return ABS_WIN_PATH_REGEX.test(torrentInfo.content_path)
368
+ ? path.win32.dirname(torrentInfo.content_path)
369
+ : path.posix.dirname(torrentInfo.content_path);
370
+ }
371
+ return torrentInfo.save_path;
372
+ }
373
+ /*
374
+ * @return array of all torrents in the client
375
+ */
376
+ async getAllTorrentInfo(options) {
377
+ const params = new URLSearchParams();
378
+ if (options?.includeFiles)
379
+ params.append("includeFiles", "true");
380
+ if (options?.includeTrackers)
381
+ params.append("includeTrackers", "true");
382
+ const responseText = await this.request("/torrents/info", params);
383
+ if (!responseText)
384
+ return [];
385
+ return JSON.parse(responseText);
386
+ }
387
+ /*
388
+ * @param hash the hash of the torrent
389
+ * @return the torrent if it exists
390
+ */
391
+ async getTorrentInfo(hash, numRetries = 0) {
392
+ if (!hash)
393
+ return undefined;
394
+ const retries = Math.max(numRetries, 0);
395
+ for (let i = 0; i <= retries; i++) {
396
+ const responseText = await this.request("/torrents/info", `hashes=${hash}`, X_WWW_FORM_URLENCODED);
397
+ if (responseText) {
398
+ const torrents = JSON.parse(responseText);
399
+ if (torrents.length > 0) {
400
+ return torrents[0];
401
+ }
402
+ }
403
+ const torrents = await this.getAllTorrentInfo();
404
+ const torrentInfo = torrents.find((torrent) => hash === torrent.hash ||
405
+ hash === torrent.infohash_v1 ||
406
+ hash === torrent.infohash_v2);
407
+ if (torrentInfo) {
408
+ return torrentInfo;
409
+ }
410
+ if (i < retries) {
411
+ await wait(Math.min(ms("1 second") * 2 ** i, ms("10 seconds")));
412
+ }
413
+ }
414
+ return undefined;
415
+ }
416
+ /**
417
+ * @return array of all torrents in the client
418
+ */
419
+ async getAllTorrents() {
420
+ const torrents = await this.getAllTorrentInfo({
421
+ includeTrackers: true,
422
+ });
423
+ return torrents.map((torrent) => ({
424
+ infoHash: torrent.hash,
425
+ category: torrent.category,
426
+ tags: torrent.tags.length ? torrent.tags.split(",") : [],
427
+ trackers: torrent.trackers
428
+ ? organizeTrackers(torrent.trackers)
429
+ : torrent.tracker.length
430
+ ? [torrent.tracker]
431
+ : undefined,
432
+ }));
433
+ }
434
+ /**
435
+ * Get all searchees from the client and update the db
436
+ * @param options.newSearcheesOnly only return searchees that are not in the db
437
+ * @param options.refresh undefined uses the cache, [] refreshes all searchees, or a list of infoHashes to refresh
438
+ * @param options.includeFiles include files in the torrents info request
439
+ * @param options.includeTrackers include trackers in the torrents info request
440
+ * @return an object containing all searchees and new searchees (refreshed searchees are considered new)
441
+ */
442
+ async getClientSearchees(options) {
443
+ const searchees = [];
444
+ const newSearchees = [];
445
+ const infoHashes = new Set();
446
+ const torrents = await this.getAllTorrentInfo({
447
+ includeFiles: options?.includeFiles,
448
+ includeTrackers: options?.includeTrackers,
449
+ });
450
+ if (!torrents.length) {
451
+ logger.error({
452
+ label: this.label,
453
+ message: "No torrents found in client",
454
+ });
455
+ return { searchees, newSearchees };
456
+ }
457
+ for (const torrent of torrents) {
458
+ const infoHash = (torrent.infohash_v1 || torrent.hash).toLowerCase();
459
+ infoHashes.add(infoHash);
460
+ const dbTorrent = await db("client_searchee")
461
+ .where("info_hash", infoHash)
462
+ .where("client_host", this.clientHost)
463
+ .first();
464
+ const { name } = torrent;
465
+ const savePath = torrent.save_path;
466
+ const category = torrent.category;
467
+ const tags = torrent.tags.length
468
+ ? torrent.tags
469
+ .split(",")
470
+ .map((tag) => tag.trim())
471
+ .filter((tag) => tag.length)
472
+ : [];
473
+ const modified = clientSearcheeModified(this.label, dbTorrent, name, savePath, {
474
+ category,
475
+ tags,
476
+ });
477
+ const refresh = options?.refresh === undefined
478
+ ? false
479
+ : options.refresh.length === 0
480
+ ? true
481
+ : options.refresh.includes(infoHash);
482
+ if (!modified && !refresh) {
483
+ if (!options?.newSearcheesOnly) {
484
+ searchees.push(createSearcheeFromDB(dbTorrent));
485
+ }
486
+ continue;
487
+ }
488
+ const files = torrent.files?.map(this.torrentFileToFile) ??
489
+ (await this.getFiles(torrent.hash));
490
+ if (!files) {
491
+ logger.verbose({
492
+ label: this.label,
493
+ message: `Failed to get files for ${torrent.name} [${sanitizeInfoHash(torrent.hash)}] (likely transient)`,
494
+ });
495
+ continue;
496
+ }
497
+ if (!files.length) {
498
+ logger.verbose({
499
+ label: this.label,
500
+ message: `No files found for ${torrent.name} [${sanitizeInfoHash(torrent.hash)}]: skipping`,
501
+ });
502
+ continue;
503
+ }
504
+ const trackers = torrent.trackers
505
+ ? organizeTrackers(torrent.trackers)
506
+ : await this.getTrackers(torrent.hash);
507
+ if (!trackers) {
508
+ logger.verbose({
509
+ label: this.label,
510
+ message: `Failed to get trackers for ${torrent.name} [${sanitizeInfoHash(torrent.hash)}] (likely transient)`,
511
+ });
512
+ continue;
513
+ }
514
+ const title = parseTitle(name, files) ?? name;
515
+ const length = torrent.total_size;
516
+ const searchee = {
517
+ infoHash,
518
+ name,
519
+ title,
520
+ files,
521
+ length,
522
+ clientHost: this.clientHost,
523
+ savePath,
524
+ category,
525
+ tags,
526
+ trackers,
527
+ };
528
+ newSearchees.push(searchee);
529
+ searchees.push(searchee);
530
+ }
531
+ await updateSearcheeClientDB(this.clientHost, newSearchees, infoHashes);
532
+ return { searchees, newSearchees };
533
+ }
534
+ /**
535
+ * @param inputHash the infohash of the torrent
536
+ * @returns whether the torrent is in client
537
+ */
538
+ async isTorrentInClient(inputHash) {
539
+ const infoHash = inputHash.toLowerCase();
540
+ try {
541
+ const torrents = await this.getAllTorrentInfo();
542
+ if (!torrents.length)
543
+ throw new Error("No torrents found");
544
+ for (const torrent of torrents) {
545
+ if (torrent.hash.toLowerCase() === infoHash ||
546
+ torrent.infohash_v1?.toLowerCase() === infoHash ||
547
+ torrent.infohash_v2?.toLowerCase() === infoHash) {
548
+ return resultOf(true);
549
+ }
550
+ }
551
+ return resultOf(false);
552
+ }
553
+ catch (e) {
554
+ return resultOfErr(e);
555
+ }
556
+ }
557
+ /**
558
+ * @param infoHash the infohash of the torrent
559
+ * @returns whether the torrent is complete
560
+ */
561
+ async isTorrentComplete(infoHash) {
562
+ const torrentInfo = await this.getTorrentInfo(infoHash);
563
+ if (!torrentInfo)
564
+ return resultOfErr("NOT_FOUND");
565
+ return resultOf(this.isTorrentInfoComplete(torrentInfo));
566
+ }
567
+ /**
568
+ * @param infoHash the infohash of the torrent
569
+ * @returns whether the torrent is checking
570
+ */
571
+ async isTorrentChecking(infoHash) {
572
+ const torrentInfo = await this.getTorrentInfo(infoHash);
573
+ if (!torrentInfo)
574
+ return resultOfErr("NOT_FOUND");
575
+ return resultOf(["checkingDL", "checkingUP"].includes(torrentInfo.state));
576
+ }
577
+ isTorrentInfoComplete(torrentInfo) {
578
+ return [
579
+ "uploading",
580
+ "pausedUP",
581
+ "stoppedUP",
582
+ "queuedUP",
583
+ "stalledUP",
584
+ "checkingUP",
585
+ "forcedUP",
586
+ ].includes(torrentInfo.state);
587
+ }
588
+ /**
589
+ * This can only return true if the searchee is from a torrent file, not API.
590
+ * Since we get the file structure from the API, it's already accounted for.
591
+ * This does NOT check if the torrent was added with "Don't Create Subfolder".
592
+ * @param data the Searchee or Metafile
593
+ * @param dataInfo the TorrentInfo
594
+ * @returns whether the torrent was added with "Create Subfolder"
595
+ */
596
+ isSubfolderContentLayout(data, dataInfo) {
597
+ const { useClientTorrents } = getRuntimeConfig();
598
+ if (useClientTorrents)
599
+ return false;
600
+ if (data.files.length > 1)
601
+ return false;
602
+ let dirname = path.posix.dirname;
603
+ let resolve = path.posix.resolve;
604
+ if (ABS_WIN_PATH_REGEX.test(dataInfo.content_path)) {
605
+ dirname = path.win32.dirname;
606
+ resolve = path.win32.resolve;
607
+ }
608
+ if (dirname(data.files[0].path) !== ".")
609
+ return false;
610
+ return (resolve(dirname(dataInfo.content_path)) !==
611
+ resolve(dataInfo.save_path));
612
+ }
613
+ /**
614
+ * This can only return true if the searchee is from a torrent file, not API.
615
+ * Since we get the file structure from the API, it's already accounted for.
616
+ * This does NOT check if the torrent was added with "Create Subfolder".
617
+ * @param data the Searchee or Metafile
618
+ * @param dataInfo the TorrentInfo
619
+ * @returns whether the torrent was added with "Don't Create Subfolder"
620
+ */
621
+ isNoSubfolderContentLayout(data, dataInfo) {
622
+ const { useClientTorrents } = getRuntimeConfig();
623
+ if (useClientTorrents)
624
+ return false;
625
+ if (data.files.length > 1) {
626
+ return dataInfo.content_path === dataInfo.save_path;
627
+ }
628
+ let dirname = path.posix.dirname;
629
+ let relative = path.posix.relative;
630
+ if (ABS_WIN_PATH_REGEX.test(dataInfo.content_path)) {
631
+ dirname = path.win32.dirname;
632
+ relative = path.win32.relative;
633
+ }
634
+ if (dirname(data.files[0].path) === ".")
635
+ return false;
636
+ const clientPath = relative(dataInfo.save_path, dataInfo.content_path);
637
+ return (getPathParts(clientPath, dirname).length <
638
+ getPathParts(data.files[0].path, dirname).length);
639
+ }
640
+ async resumeInjection(meta, decision, options) {
641
+ const infoHash = meta.infoHash;
642
+ let sleepTime = resumeSleepTime;
643
+ const stopTime = getResumeStopTime();
644
+ let stop = false;
645
+ while (Date.now() < stopTime) {
646
+ if (options.checkOnce) {
647
+ if (stop)
648
+ return;
649
+ stop = true;
650
+ }
651
+ await wait(sleepTime);
652
+ const torrentInfo = await this.getTorrentInfo(infoHash);
653
+ if (!torrentInfo) {
654
+ sleepTime = resumeErrSleepTime; // Dropping connections or restart
655
+ continue;
656
+ }
657
+ if (["checkingDL", "checkingUP"].includes(torrentInfo.state)) {
658
+ continue;
659
+ }
660
+ const torrentLog = `${torrentInfo.name} [${sanitizeInfoHash(infoHash)}]`;
661
+ if (!["pausedDL", "stoppedDL", "pausedUP", "stoppedUP"].includes(torrentInfo.state)) {
662
+ logger.warn({
663
+ label: this.label,
664
+ message: `Will not resume ${torrentLog}: state is ${torrentInfo.state}`,
665
+ });
666
+ return;
667
+ }
668
+ const maxRemainingBytes = getMaxRemainingBytes(meta, decision, {
669
+ torrentLog,
670
+ label: this.label,
671
+ });
672
+ if (torrentInfo.amount_left > maxRemainingBytes) {
673
+ if (!shouldResumeFromNonRelevantFiles(meta, torrentInfo.amount_left, decision, { torrentLog, label: this.label })) {
674
+ logger.warn({
675
+ label: this.label,
676
+ message: `autoResumeMaxDownload will not resume ${torrentLog}: remainingSize ${humanReadableSize(torrentInfo.amount_left, { binary: true })} > ${humanReadableSize(maxRemainingBytes, { binary: true })} limit`,
677
+ });
678
+ return;
679
+ }
680
+ }
681
+ logger.info({
682
+ label: this.label,
683
+ message: `Resuming ${torrentLog}: ${humanReadableSize(torrentInfo.amount_left, { binary: true })} remaining`,
684
+ });
685
+ await this.request(`/torrents/${this.versionMajor >= 5 ? "start" : "resume"}`, `hashes=${infoHash}`, X_WWW_FORM_URLENCODED);
686
+ return;
687
+ }
688
+ logger.warn({
689
+ label: this.label,
690
+ message: `Will not resume torrent ${infoHash}: timeout`,
691
+ });
692
+ }
693
+ async inject(newTorrent, searchee, decision, options) {
694
+ const { linkCategory } = getRuntimeConfig();
695
+ try {
696
+ const existsRes = await this.isTorrentInClient(newTorrent.infoHash);
697
+ if (existsRes.isErr())
698
+ return InjectionResult.FAILURE;
699
+ if (existsRes.unwrap())
700
+ return InjectionResult.ALREADY_EXISTS;
701
+ const searcheeInfo = await this.getTorrentInfo(searchee.infoHash);
702
+ if (!searcheeInfo) {
703
+ if (!options.destinationDir) {
704
+ // This is never possible, being made explicit here
705
+ throw new Error(`Searchee torrent may have been deleted: ${getLogString(searchee)}`);
706
+ }
707
+ else if (searchee.infoHash) {
708
+ logger.warn({
709
+ label: this.label,
710
+ message: `Searchee torrent may have been deleted, tagging may not meet expectations: ${getLogString(searchee)}`,
711
+ });
712
+ }
713
+ }
714
+ const { savePath, isComplete, autoTMM, category } = options.destinationDir
715
+ ? {
716
+ savePath: options.destinationDir,
717
+ isComplete: true,
718
+ autoTMM: false,
719
+ category: linkCategory,
720
+ }
721
+ : {
722
+ savePath: searcheeInfo.save_path,
723
+ isComplete: this.isTorrentInfoComplete(searcheeInfo),
724
+ autoTMM: searcheeInfo.auto_tmm,
725
+ category: searcheeInfo.category,
726
+ };
727
+ if (options.onlyCompleted && !isComplete) {
728
+ return InjectionResult.TORRENT_NOT_COMPLETE;
729
+ }
730
+ const filename = `${newTorrent.getFileSystemSafeName()}.${TORRENT_TAG}.torrent`;
731
+ const buffer = new Blob([new Uint8Array(newTorrent.encode())], {
732
+ type: "application/x-bittorrent",
733
+ });
734
+ const toRecheck = shouldRecheck(newTorrent, decision);
735
+ // ---------------------- Building form data ----------------------
736
+ const formData = new FormData();
737
+ formData.append("torrents", buffer, filename);
738
+ if (!autoTMM) {
739
+ formData.append("downloadPath", savePath);
740
+ formData.append("savepath", savePath);
741
+ }
742
+ formData.append("autoTMM", autoTMM.toString());
743
+ if (category?.length) {
744
+ formData.append("category", await this.getCategoryForNewTorrent(category, savePath, autoTMM));
745
+ }
746
+ formData.append("tags", this.getTagsForNewTorrent(searcheeInfo, options.destinationDir));
747
+ formData.append("contentLayout", this.getLayoutForNewTorrent(searchee, searcheeInfo, options.destinationDir));
748
+ formData.append("skip_checking", (!toRecheck).toString());
749
+ formData.append(this.versionMajor >= 5 ? "stopped" : "paused", toRecheck.toString());
750
+ // for some reason the parser parses the last kv pair incorrectly
751
+ // it concats the value and the sentinel
752
+ formData.append("foo", "bar");
753
+ try {
754
+ await this.addTorrent(formData);
755
+ }
756
+ catch (e) {
757
+ logger.error({
758
+ label: this.label,
759
+ message: `Failed to add torrent (polling client to confirm): ${e.message}`,
760
+ });
761
+ logger.debug(e);
762
+ }
763
+ const newInfo = await this.getTorrentInfo(newTorrent.infoHash, 5);
764
+ if (!newInfo) {
765
+ throw new Error(`Failed to retrieve torrent after adding`);
766
+ }
767
+ if (toRecheck) {
768
+ await this.recheckTorrent(newInfo.hash);
769
+ void this.resumeInjection(newTorrent, decision, {
770
+ checkOnce: false,
771
+ });
772
+ }
773
+ return InjectionResult.SUCCESS;
774
+ }
775
+ catch (e) {
776
+ logger.error({
777
+ label: this.label,
778
+ message: `Injection failed for ${getLogString(newTorrent)}: ${e.message}`,
779
+ });
780
+ logger.debug(e);
781
+ return InjectionResult.FAILURE;
782
+ }
783
+ }
784
+ }
785
+ //# sourceMappingURL=QBittorrent.js.map