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