diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 1cf3bf079..f13e4aab1 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -3,6 +3,7 @@
+
diff --git a/.idea/dictionaries/mmgoodnow.xml b/.idea/dictionaries/mmgoodnow.xml
index 42f962d42..88a80c8c5 100644
--- a/.idea/dictionaries/mmgoodnow.xml
+++ b/.idea/dictionaries/mmgoodnow.xml
@@ -1,6 +1,7 @@
+ backfill
bencode
caronc
dlspeed
@@ -9,6 +10,7 @@
jsified
knexfile
leechs
+ metainfo
qbittorrent
rtorrent
savepath
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 03d9549ea..fe7bb5740 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -2,5 +2,6 @@
+
\ No newline at end of file
diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml
index 66f594773..afac610da 100644
--- a/.idea/jsLibraryMappings.xml
+++ b/.idea/jsLibraryMappings.xml
@@ -1,8 +1,6 @@
-
-
\ No newline at end of file
diff --git a/.idea/prettier.xml b/.idea/prettier.xml
index 00a1542a7..0c543a641 100644
--- a/.idea/prettier.xml
+++ b/.idea/prettier.xml
@@ -3,6 +3,6 @@
-
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index cbd9d9655..ec6e5b275 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,6 @@
-FROM node:14
-WORKDIR /usr/src/app
+FROM node:16
+WORKDIR /usr/src/cross-seed
+RUN npm install -g npm@9
COPY package*.json ./
RUN npm ci
ENV CONFIG_DIR=/config
@@ -9,4 +10,5 @@ COPY src src
RUN npm run build
RUN npm link
EXPOSE 2468
+WORKDIR /config
ENTRYPOINT ["cross-seed"]
diff --git a/README.md b/README.md
index 4a03a6f1f..dff11f4db 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ to see the steps required for migration.
## Requirements
-- [Node 14+](https://nodejs.org/en/download)
+- [Node 16+](https://nodejs.org/en/download)
- Any number of indexers that support Torznab (use Jackett or Prowlarr to
help)
diff --git a/package-lock.json b/package-lock.json
index 55829ea60..8815e23d0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,13 +10,13 @@
"license": "Apache-2.0",
"dependencies": {
"bencode": "^2.0.1",
- "better-sqlite3": "^7.5.0",
+ "better-sqlite3": "^8.0.1",
"chalk": "^5.0.0",
"commander": "^8.3.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10",
"fuse.js": "^6.6.2",
- "knex": "^1.0.4",
+ "knex": "^2.4.2",
"lodash-es": "^4.17.21",
"ms": "^2.1.3",
"node-fetch": "^3.2.0",
@@ -45,7 +45,7 @@
"typescript": "^4.7.4"
},
"engines": {
- "node": ">=14"
+ "node": ">=16"
}
},
"node_modules/@dabh/diagnostics": {
@@ -503,6 +503,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
"engines": {
"node": ">=8"
}
@@ -522,47 +523,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/aproba": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
- "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
- },
- "node_modules/are-we-there-yet": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
- "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==",
- "dependencies": {
- "delegates": "^1.0.0",
- "readable-stream": "^2.0.6"
- }
- },
- "node_modules/are-we-there-yet/node_modules/readable-stream": {
- "version": "2.3.7",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
- "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
- "dependencies": {
- "core-util-is": "~1.0.0",
- "inherits": "~2.0.3",
- "isarray": "~1.0.0",
- "process-nextick-args": "~2.0.0",
- "safe-buffer": "~5.1.1",
- "string_decoder": "~1.1.1",
- "util-deprecate": "~1.0.1"
- }
- },
- "node_modules/are-we-there-yet/node_modules/safe-buffer": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
- "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
- },
- "node_modules/are-we-there-yet/node_modules/string_decoder": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
- "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
- "dependencies": {
- "safe-buffer": "~5.1.0"
- }
- },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -627,13 +587,13 @@
"integrity": "sha512-ct6s33iiwRCUPp9KXnJ4QMWDgHIgaw36caK/5XEQ9L8dCzSQlJt1Vk6VmHh1VD4AlGCAI4C2zmtfItifBBPrhQ=="
},
"node_modules/better-sqlite3": {
- "version": "7.5.0",
- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-7.5.0.tgz",
- "integrity": "sha512-6FdG9DoytYGDhLW7VWW1vxjEz7xHkqK6LnaUQYA8d6GHNgZhu9PFX2xwKEEnSBRoT1J4PjTUPeg217ShxNmuPg==",
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.0.1.tgz",
+ "integrity": "sha512-JhTZjpyapA1icCEjIZB4TSSgkGdFgpWZA2Wszg7Cf4JwJwKQmbvuNnJBeR+EYG/Z29OXvR4G//Rbg31BW/Z7Yg==",
"hasInstallScript": true,
"dependencies": {
"bindings": "^1.5.0",
- "prebuild-install": "^7.0.0"
+ "prebuild-install": "^7.1.0"
}
},
"node_modules/bindings": {
@@ -779,14 +739,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/code-point-at": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
- "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
@@ -836,9 +788,9 @@
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"node_modules/colorette": {
- "version": "2.0.16",
- "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz",
- "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g=="
+ "version": "2.0.19",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz",
+ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ=="
},
"node_modules/colors": {
"version": "1.4.0",
@@ -870,16 +822,6 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
- "node_modules/console-control-strings": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
- "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
- },
- "node_modules/core-util-is": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
- "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
- },
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -903,9 +845,9 @@
}
},
"node_modules/debug": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
- "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dependencies": {
"ms": "2.1.2"
},
@@ -937,11 +879,6 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
- "node_modules/delegates": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
- "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o="
- },
"node_modules/detect-libc": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz",
@@ -977,7 +914,8 @@
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
},
"node_modules/enabled": {
"version": "2.0.0",
@@ -1492,62 +1430,12 @@
"node": ">=10"
}
},
- "node_modules/gauge": {
- "version": "2.7.4",
- "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
- "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
- "dependencies": {
- "aproba": "^1.0.3",
- "console-control-strings": "^1.0.0",
- "has-unicode": "^2.0.0",
- "object-assign": "^4.1.0",
- "signal-exit": "^3.0.0",
- "string-width": "^1.0.1",
- "strip-ansi": "^3.0.1",
- "wide-align": "^1.1.0"
- }
- },
- "node_modules/gauge/node_modules/ansi-regex": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
- "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
+ "node_modules/get-package-type": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/gauge/node_modules/is-fullwidth-code-point": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
- "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
- "dependencies": {
- "number-is-nan": "^1.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/gauge/node_modules/string-width": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
- "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
- "dependencies": {
- "code-point-at": "^1.0.0",
- "is-fullwidth-code-point": "^1.0.0",
- "strip-ansi": "^3.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/gauge/node_modules/strip-ansi": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
- "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
- "dependencies": {
- "ansi-regex": "^2.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
+ "node": ">=8.0.0"
}
},
"node_modules/get-stdin": {
@@ -1569,7 +1457,7 @@
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
- "integrity": "sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4="
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
},
"node_modules/glob": {
"version": "7.2.0",
@@ -1657,11 +1545,6 @@
"node": ">=8"
}
},
- "node_modules/has-unicode": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
- "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
- },
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -1790,6 +1673,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
"engines": {
"node": ">=8"
}
@@ -1826,11 +1710,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/isarray": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
- "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
- },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -1862,15 +1741,16 @@
"dev": true
},
"node_modules/knex": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/knex/-/knex-1.0.4.tgz",
- "integrity": "sha512-cMQ81fpkVmr4ia20BtyrD3oPere/ir/Q6IGLAgcREKOzRVhMsasQ4nx1VQuDRJjqq6oK5kfcxmvWoYkHKrnuMA==",
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/knex/-/knex-2.4.2.tgz",
+ "integrity": "sha512-tMI1M7a+xwHhPxjbl/H9K1kHX+VncEYcvCx5K00M16bWvpYPKAZd6QrCu68PtHAdIZNQPWZn0GVhqVBEthGWCg==",
"dependencies": {
- "colorette": "2.0.16",
- "commander": "^8.3.0",
- "debug": "4.3.3",
+ "colorette": "2.0.19",
+ "commander": "^9.1.0",
+ "debug": "4.3.4",
"escalade": "^3.1.1",
"esm": "^3.2.25",
+ "get-package-type": "^0.1.0",
"getopts": "2.3.0",
"interpret": "^2.2.0",
"lodash": "^4.17.21",
@@ -1887,9 +1767,6 @@
"node": ">=12"
},
"peerDependenciesMeta": {
- "@vscode/sqlite3": {
- "optional": true
- },
"better-sqlite3": {
"optional": true
},
@@ -1905,11 +1782,22 @@
"pg-native": {
"optional": true
},
+ "sqlite3": {
+ "optional": true
+ },
"tedious": {
"optional": true
}
}
},
+ "node_modules/knex/node_modules/commander": {
+ "version": "9.4.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz",
+ "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==",
+ "engines": {
+ "node": "^12.20.0 || >=14"
+ }
+ },
"node_modules/knex/node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
@@ -2269,9 +2157,9 @@
}
},
"node_modules/minimatch": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
- "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -2280,9 +2168,12 @@
}
},
"node_modules/minimist": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
- "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
+ "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
@@ -2314,9 +2205,9 @@
"dev": true
},
"node_modules/node-abi": {
- "version": "3.8.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.8.0.tgz",
- "integrity": "sha512-tzua9qWWi7iW4I42vUPKM+SfaF0vQSLAm4yO5J83mSwB7GeoWrDKC/K+8YCnYNwqP5duwazbw2X9l4m8SC2cUw==",
+ "version": "3.30.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.30.0.tgz",
+ "integrity": "sha512-qWO5l3SCqbwQavymOmtTVuCWZE23++S+rxyoHjXqUmPyzRcaoI4lA2gO55/drddGnedAyjA7sk76SfQ5lfUMnw==",
"dependencies": {
"semver": "^7.3.5"
},
@@ -2380,33 +2271,6 @@
"node": ">=8"
}
},
- "node_modules/npmlog": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
- "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
- "dependencies": {
- "are-we-there-yet": "~1.1.2",
- "console-control-strings": "~1.1.0",
- "gauge": "~2.7.3",
- "set-blocking": "~2.0.0"
- }
- },
- "node_modules/number-is-nan": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
- "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/object-assign": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
@@ -2579,9 +2443,9 @@
}
},
"node_modules/prebuild-install": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.0.1.tgz",
- "integrity": "sha512-QBSab31WqkyxpnMWQxubYAHR5S9B2+r81ucocew34Fkl98FhvKIF50jIJnNOBmAZfyNV7vE5T6gd3hTVWgY6tg==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz",
+ "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
@@ -2590,7 +2454,6 @@
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^1.0.1",
"node-abi": "^3.3.0",
- "npmlog": "^4.0.1",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
@@ -2625,11 +2488,6 @@
"node": ">=10.13.0"
}
},
- "node_modules/process-nextick-args": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
- "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
- },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -2693,7 +2551,7 @@
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
- "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"engines": {
"node": ">=0.10.0"
}
@@ -2882,11 +2740,6 @@
"node": ">=10"
}
},
- "node_modules/set-blocking": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
- "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
- },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -2911,7 +2764,8 @@
"node_modules/signal-exit": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.6.tgz",
- "integrity": "sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ=="
+ "integrity": "sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ==",
+ "dev": true
},
"node_modules/simple-concat": {
"version": "1.0.1",
@@ -3055,6 +2909,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -3068,6 +2923,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -3212,7 +3068,7 @@
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
- "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dependencies": {
"safe-buffer": "^5.0.1"
},
@@ -3300,14 +3156,6 @@
"node": ">= 8"
}
},
- "node_modules/wide-align": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
- "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
- "dependencies": {
- "string-width": "^1.0.2 || 2 || 3 || 4"
- }
- },
"node_modules/winston": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.3.3.tgz",
@@ -3771,7 +3619,8 @@
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true
},
"ansi-styles": {
"version": "4.3.0",
@@ -3782,49 +3631,6 @@
"color-convert": "^2.0.1"
}
},
- "aproba": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
- "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
- },
- "are-we-there-yet": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
- "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==",
- "requires": {
- "delegates": "^1.0.0",
- "readable-stream": "^2.0.6"
- },
- "dependencies": {
- "readable-stream": {
- "version": "2.3.7",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
- "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
- "requires": {
- "core-util-is": "~1.0.0",
- "inherits": "~2.0.3",
- "isarray": "~1.0.0",
- "process-nextick-args": "~2.0.0",
- "safe-buffer": "~5.1.1",
- "string_decoder": "~1.1.1",
- "util-deprecate": "~1.0.1"
- }
- },
- "safe-buffer": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
- "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
- },
- "string_decoder": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
- "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
- "requires": {
- "safe-buffer": "~5.1.0"
- }
- }
- }
- },
"argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -3869,12 +3675,12 @@
"integrity": "sha512-ct6s33iiwRCUPp9KXnJ4QMWDgHIgaw36caK/5XEQ9L8dCzSQlJt1Vk6VmHh1VD4AlGCAI4C2zmtfItifBBPrhQ=="
},
"better-sqlite3": {
- "version": "7.5.0",
- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-7.5.0.tgz",
- "integrity": "sha512-6FdG9DoytYGDhLW7VWW1vxjEz7xHkqK6LnaUQYA8d6GHNgZhu9PFX2xwKEEnSBRoT1J4PjTUPeg217ShxNmuPg==",
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.0.1.tgz",
+ "integrity": "sha512-JhTZjpyapA1icCEjIZB4TSSgkGdFgpWZA2Wszg7Cf4JwJwKQmbvuNnJBeR+EYG/Z29OXvR4G//Rbg31BW/Z7Yg==",
"requires": {
"bindings": "^1.5.0",
- "prebuild-install": "^7.0.0"
+ "prebuild-install": "^7.1.0"
}
},
"bindings": {
@@ -3968,11 +3774,6 @@
"string-width": "^4.2.0"
}
},
- "code-point-at": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
- "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
- },
"color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
@@ -4021,9 +3822,9 @@
}
},
"colorette": {
- "version": "2.0.16",
- "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz",
- "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g=="
+ "version": "2.0.19",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz",
+ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ=="
},
"colors": {
"version": "1.4.0",
@@ -4049,16 +3850,6 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
- "console-control-strings": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
- "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
- },
- "core-util-is": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
- "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
- },
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -4076,9 +3867,9 @@
"integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA=="
},
"debug": {
- "version": "4.3.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
- "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"requires": {
"ms": "2.1.2"
},
@@ -4101,11 +3892,6 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
- "delegates": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
- "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o="
- },
"detect-libc": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz",
@@ -4132,7 +3918,8 @@
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
},
"enabled": {
"version": "2.0.0",
@@ -4527,53 +4314,10 @@
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz",
"integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA=="
},
- "gauge": {
- "version": "2.7.4",
- "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
- "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
- "requires": {
- "aproba": "^1.0.3",
- "console-control-strings": "^1.0.0",
- "has-unicode": "^2.0.0",
- "object-assign": "^4.1.0",
- "signal-exit": "^3.0.0",
- "string-width": "^1.0.1",
- "strip-ansi": "^3.0.1",
- "wide-align": "^1.1.0"
- },
- "dependencies": {
- "ansi-regex": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
- "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
- },
- "is-fullwidth-code-point": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
- "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
- "requires": {
- "number-is-nan": "^1.0.0"
- }
- },
- "string-width": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
- "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
- "requires": {
- "code-point-at": "^1.0.0",
- "is-fullwidth-code-point": "^1.0.0",
- "strip-ansi": "^3.0.0"
- }
- },
- "strip-ansi": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
- "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
- "requires": {
- "ansi-regex": "^2.0.0"
- }
- }
- }
+ "get-package-type": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="
},
"get-stdin": {
"version": "8.0.0",
@@ -4588,7 +4332,7 @@
"github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
- "integrity": "sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4="
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
},
"glob": {
"version": "7.2.0",
@@ -4649,11 +4393,6 @@
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
- "has-unicode": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
- "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
- },
"human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -4734,7 +4473,8 @@
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true
},
"is-glob": {
"version": "4.0.3",
@@ -4756,11 +4496,6 @@
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="
},
- "isarray": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
- "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
- },
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -4789,15 +4524,16 @@
"dev": true
},
"knex": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/knex/-/knex-1.0.4.tgz",
- "integrity": "sha512-cMQ81fpkVmr4ia20BtyrD3oPere/ir/Q6IGLAgcREKOzRVhMsasQ4nx1VQuDRJjqq6oK5kfcxmvWoYkHKrnuMA==",
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/knex/-/knex-2.4.2.tgz",
+ "integrity": "sha512-tMI1M7a+xwHhPxjbl/H9K1kHX+VncEYcvCx5K00M16bWvpYPKAZd6QrCu68PtHAdIZNQPWZn0GVhqVBEthGWCg==",
"requires": {
- "colorette": "2.0.16",
- "commander": "^8.3.0",
- "debug": "4.3.3",
+ "colorette": "2.0.19",
+ "commander": "^9.1.0",
+ "debug": "4.3.4",
"escalade": "^3.1.1",
"esm": "^3.2.25",
+ "get-package-type": "^0.1.0",
"getopts": "2.3.0",
"interpret": "^2.2.0",
"lodash": "^4.17.21",
@@ -4808,6 +4544,11 @@
"tildify": "2.0.0"
},
"dependencies": {
+ "commander": {
+ "version": "9.4.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz",
+ "integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw=="
+ },
"resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
@@ -5055,17 +4796,17 @@
"dev": true
},
"minimatch": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
- "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"requires": {
"brace-expansion": "^1.1.7"
}
},
"minimist": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
- "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
+ "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g=="
},
"mkdirp-classic": {
"version": "0.5.3",
@@ -5094,9 +4835,9 @@
"dev": true
},
"node-abi": {
- "version": "3.8.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.8.0.tgz",
- "integrity": "sha512-tzua9qWWi7iW4I42vUPKM+SfaF0vQSLAm4yO5J83mSwB7GeoWrDKC/K+8YCnYNwqP5duwazbw2X9l4m8SC2cUw==",
+ "version": "3.30.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.30.0.tgz",
+ "integrity": "sha512-qWO5l3SCqbwQavymOmtTVuCWZE23++S+rxyoHjXqUmPyzRcaoI4lA2gO55/drddGnedAyjA7sk76SfQ5lfUMnw==",
"requires": {
"semver": "^7.3.5"
}
@@ -5131,27 +4872,6 @@
"path-key": "^3.0.0"
}
},
- "npmlog": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
- "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
- "requires": {
- "are-we-there-yet": "~1.1.2",
- "console-control-strings": "~1.1.0",
- "gauge": "~2.7.3",
- "set-blocking": "~2.0.0"
- }
- },
- "number-is-nan": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
- "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
- },
- "object-assign": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
- },
"object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
@@ -5268,9 +4988,9 @@
"dev": true
},
"prebuild-install": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.0.1.tgz",
- "integrity": "sha512-QBSab31WqkyxpnMWQxubYAHR5S9B2+r81ucocew34Fkl98FhvKIF50jIJnNOBmAZfyNV7vE5T6gd3hTVWgY6tg==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz",
+ "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==",
"requires": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
@@ -5279,7 +4999,6 @@
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^1.0.1",
"node-abi": "^3.3.0",
- "npmlog": "^4.0.1",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
@@ -5299,11 +5018,6 @@
"integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==",
"dev": true
},
- "process-nextick-args": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
- "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
- },
"progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -5344,7 +5058,7 @@
"strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
- "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="
}
}
},
@@ -5464,11 +5178,6 @@
"lru-cache": "^6.0.0"
}
},
- "set-blocking": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
- "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
- },
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5487,7 +5196,8 @@
"signal-exit": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.6.tgz",
- "integrity": "sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ=="
+ "integrity": "sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ==",
+ "dev": true
},
"simple-concat": {
"version": "1.0.1",
@@ -5583,6 +5293,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -5593,6 +5304,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
"requires": {
"ansi-regex": "^5.0.1"
}
@@ -5701,7 +5413,7 @@
"tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
- "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"requires": {
"safe-buffer": "^5.0.1"
}
@@ -5761,14 +5473,6 @@
"isexe": "^2.0.0"
}
},
- "wide-align": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
- "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
- "requires": {
- "string-width": "^1.0.2 || 2 || 3 || 4"
- }
- },
"winston": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.3.3.tgz",
diff --git a/package.json b/package.json
index e8282e38a..d36caa37c 100644
--- a/package.json
+++ b/package.json
@@ -5,13 +5,14 @@
"scripts": {
"test": "true",
"build": "tsc",
- "prepare": "rimraf dist && npm run build && husky install"
+ "watch": "tsc --watch",
+ "prepublishOnly": "rimraf dist && npm run build && husky install"
},
"bin": {
"cross-seed": "dist/cmd.js"
},
"engines": {
- "node": ">=14"
+ "node": ">=16"
},
"files": [
"dist/"
@@ -25,13 +26,13 @@
"license": "Apache-2.0",
"dependencies": {
"bencode": "^2.0.1",
- "better-sqlite3": "^7.5.0",
+ "better-sqlite3": "^8.0.1",
"chalk": "^5.0.0",
"commander": "^8.3.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10",
"fuse.js": "^6.6.2",
- "knex": "^1.0.4",
+ "knex": "^2.4.2",
"lodash-es": "^4.17.21",
"ms": "^2.1.3",
"node-fetch": "^3.2.0",
diff --git a/src/Result.ts b/src/Result.ts
new file mode 100644
index 000000000..47d410670
--- /dev/null
+++ b/src/Result.ts
@@ -0,0 +1,80 @@
+export interface Result {
+ isOk(): boolean;
+ isErr(): boolean;
+ mapOk(mapper: (t: T) => R): Result;
+ mapErr(mapper: (u: U) => R): Result;
+ unwrapOrThrow(): T;
+ unwrapErrOrThrow(): U;
+}
+
+class OkResult implements Result {
+ private readonly contents: T;
+
+ constructor(contents: T) {
+ this.contents = contents;
+ }
+
+ isOk() {
+ return true;
+ }
+
+ isErr() {
+ return false;
+ }
+
+ mapOk(mapper: (t: T) => R): Result {
+ return new OkResult(mapper(this.contents));
+ }
+
+ mapErr(mapper: (u: U) => R): Result {
+ return this as unknown as Result;
+ }
+
+ unwrapOrThrow(): T {
+ return this.contents;
+ }
+
+ unwrapErrOrThrow(): U {
+ throw new Error("Tried to unwrap an OkResult's error");
+ }
+}
+
+class ErrResult implements Result {
+ private readonly contents: U;
+
+ constructor(contents: U) {
+ this.contents = contents;
+ }
+
+ isOk(): boolean {
+ return false;
+ }
+
+ isErr(): boolean {
+ return true;
+ }
+
+ mapOk(mapper: (t: T) => R): Result {
+ return this as unknown as Result;
+ }
+
+ mapErr(mapper: (u: U) => R): Result {
+ return new ErrResult(mapper(this.contents));
+ }
+
+ unwrapOrThrow(): T {
+ throw new Error("Tried to unwrap an ErrResult's error");
+ }
+
+ unwrapErrOrThrow(): U {
+ return this.contents;
+ }
+}
+
+export function resultOf(value: T): Result {
+ return new OkResult(value);
+}
+
+export function resultOfErr(value: U): Result {
+ return new ErrResult(value);
+}
diff --git a/src/clients/QBittorrent.ts b/src/clients/QBittorrent.ts
index e0f5dc407..5a0abaff8 100644
--- a/src/clients/QBittorrent.ts
+++ b/src/clients/QBittorrent.ts
@@ -272,15 +272,15 @@ export default class QBittorrent implements TorrentClient {
newTorrent: Metafile,
searchee: Searchee
): Promise {
- const { duplicateCategories } = getRuntimeConfig();
- if (await this.isInfoHashInClient(newTorrent.infoHash)) {
- return InjectionResult.ALREADY_EXISTS;
- }
- const buf = parseTorrent.toTorrentFile(newTorrent);
- const filename = `${newTorrent.name}.cross-seed.torrent`;
- const tempFilepath = join(tmpdir(), filename);
- await writeFile(tempFilepath, buf, { mode: 0o644 });
try {
+ const { duplicateCategories } = getRuntimeConfig();
+ if (await this.isInfoHashInClient(newTorrent.infoHash)) {
+ return InjectionResult.ALREADY_EXISTS;
+ }
+ const buf = parseTorrent.toTorrentFile(newTorrent);
+ const filename = `${newTorrent.name}.cross-seed.torrent`;
+ const tempFilepath = join(tmpdir(), filename);
+ await writeFile(tempFilepath, buf, { mode: 0o644 });
const { save_path, isComplete, autoTMM, category } =
await this.getTorrentConfiguration(searchee);
diff --git a/src/clients/TorrentClient.ts b/src/clients/TorrentClient.ts
index e7c3de20e..fb4d1e8f4 100644
--- a/src/clients/TorrentClient.ts
+++ b/src/clients/TorrentClient.ts
@@ -4,6 +4,7 @@ import { getRuntimeConfig, NonceOptions } from "../runtimeConfig.js";
import { Searchee } from "../searchee.js";
import QBittorrent from "./QBittorrent.js";
import RTorrent from "./RTorrent.js";
+import Transmission from "./Transmission.js";
let activeClient: TorrentClient;
@@ -17,11 +18,14 @@ export interface TorrentClient {
}
function instantiateDownloadClient() {
- const { rtorrentRpcUrl, qbittorrentUrl } = getRuntimeConfig();
+ const { rtorrentRpcUrl, qbittorrentUrl, transmissionRpcUrl } =
+ getRuntimeConfig();
if (rtorrentRpcUrl) {
activeClient = new RTorrent();
} else if (qbittorrentUrl) {
activeClient = new QBittorrent();
+ } else if (transmissionRpcUrl) {
+ activeClient = new Transmission();
}
}
diff --git a/src/clients/Transmission.ts b/src/clients/Transmission.ts
new file mode 100644
index 000000000..212e37c76
--- /dev/null
+++ b/src/clients/Transmission.ts
@@ -0,0 +1,170 @@
+import fetch, { Response as FetchResponse, Headers } from "node-fetch";
+import parseTorrent, { Metafile } from "parse-torrent";
+import { InjectionResult } from "../constants.js";
+import { CrossSeedError } from "../errors.js";
+import { Label, logger } from "../logger.js";
+import { getRuntimeConfig, NonceOptions } from "../runtimeConfig.js";
+import { Searchee } from "../searchee.js";
+import { TorrentClient } from "./TorrentClient.js";
+
+const XTransmissionSessionId = "X-Transmission-Session-Id";
+type Method = "session-get" | "torrent-add" | "torrent-get";
+
+interface Response {
+ result: "success" | string;
+ arguments: T;
+}
+
+interface TorrentGetResponseArgs {
+ torrents: { downloadDir: string; percentDone: number }[];
+}
+
+interface TorrentMetadata {
+ hashString: string;
+ id: number;
+ name: string;
+}
+
+type TorrentDuplicateResponse = { "torrent-duplicate": TorrentMetadata };
+type TorrentAddedResponse = { "torrent-added": TorrentMetadata };
+type TorrentAddResponse = TorrentAddedResponse | TorrentDuplicateResponse;
+
+function doesAlreadyExist(
+ args: TorrentAddResponse
+): args is TorrentDuplicateResponse {
+ return "torrent-duplicate" in args;
+}
+
+export default class Transmission implements TorrentClient {
+ xTransmissionSessionId: string;
+
+ private async request(
+ method: Method,
+ args: unknown = {},
+ retries = 1
+ ): Promise {
+ const { transmissionRpcUrl } = getRuntimeConfig();
+
+ const { username, password, origin, pathname } = new URL(
+ transmissionRpcUrl
+ );
+
+ const headers = new Headers();
+ headers.set("Content-Type", "application/json");
+ if (this.xTransmissionSessionId) {
+ headers.set(XTransmissionSessionId, this.xTransmissionSessionId);
+ }
+ if (username && password) {
+ const credentials = Buffer.from(`${username}:${password}`).toString(
+ "base64"
+ );
+ headers.set("Authorization", `Basic ${credentials}`);
+ }
+
+ const response = await fetch(origin + pathname, {
+ method: "POST",
+ body: JSON.stringify({ method, arguments: args }),
+ headers,
+ });
+ if (response.status === 409) {
+ this.xTransmissionSessionId = response.headers.get(
+ XTransmissionSessionId
+ );
+ return this.request(method, args, retries - 1);
+ }
+ try {
+ const responseBody = (await response.json()) as Response;
+ if (
+ responseBody.result === "success" ||
+ responseBody.result === "duplicate torrent" // slight hack but best solution for now
+ ) {
+ return responseBody.arguments;
+ } else {
+ throw new Error(
+ `Transmission responded with error: "${responseBody.result}"`
+ );
+ }
+ } catch (e) {
+ if (e instanceof TypeError) {
+ logger.error({
+ label: Label.TRANSMISSION,
+ message: `Transmission returned non-JSON response`,
+ });
+ logger.debug({
+ label: Label.TRANSMISSION,
+ message: response,
+ });
+ } else {
+ logger.error({
+ label: Label.TRANSMISSION,
+ message: `Transmission responded with an error`,
+ });
+ logger.debug(e);
+ }
+ throw e;
+ }
+ }
+
+ async validateConfig(): Promise {
+ try {
+ await this.request("session-get");
+ } catch (e) {
+ const { transmissionRpcUrl } = getRuntimeConfig();
+ throw new CrossSeedError(
+ `Failed to reach Transmission at ${transmissionRpcUrl}`
+ );
+ }
+ }
+
+ async inject(
+ newTorrent: Metafile,
+ searchee: Searchee,
+ nonceOptions: NonceOptions
+ ): Promise {
+ if (!searchee.infoHash) {
+ throw new CrossSeedError(
+ "inject not supported for data-based searchees"
+ );
+ }
+ let queryResponse: TorrentGetResponseArgs;
+ try {
+ queryResponse = await this.request(
+ "torrent-get",
+ {
+ fields: ["downloadDir", "percentDone"],
+ ids: [searchee.infoHash],
+ }
+ );
+ } catch (e) {
+ return InjectionResult.FAILURE;
+ }
+
+ if (queryResponse.torrents.length === 0) return InjectionResult.FAILURE;
+ const [{ downloadDir, percentDone }] = queryResponse.torrents;
+ if (percentDone < 1) return InjectionResult.TORRENT_NOT_COMPLETE;
+
+ let addResponse: TorrentAddResponse;
+
+ try {
+ addResponse = await this.request(
+ "torrent-add",
+ {
+ "download-dir": downloadDir,
+ metainfo: parseTorrent
+ .toTorrentFile(newTorrent)
+ .toString("base64"),
+ paused: false,
+ labels: ["cross-seed"],
+ }
+ );
+ } catch (e) {
+ return InjectionResult.FAILURE;
+ }
+
+ if (doesAlreadyExist(addResponse)) {
+ return InjectionResult.ALREADY_EXISTS;
+ }
+
+ return InjectionResult.SUCCESS;
+ }
+}
diff --git a/src/cmd.ts b/src/cmd.ts
index 2429797c9..30a103dc0 100755
--- a/src/cmd.ts
+++ b/src/cmd.ts
@@ -8,7 +8,7 @@ import { generateConfig, getFileConfig } from "./configuration.js";
import { Action } from "./constants.js";
import { jobsLoop } from "./jobs.js";
import { diffCmd } from "./diff.js";
-import { CrossSeedError, exitOnCrossSeedErrors } from "./errors.js";
+import { exitOnCrossSeedErrors } from "./errors.js";
import { initializeLogger, Label, logger } from "./logger.js";
import { main, scanRssFeeds } from "./pipeline.js";
import {
@@ -83,6 +83,11 @@ function createCommandWithSharedOptions(name, description) {
"Include single-episode torrents in the search",
fallback(fileConfig.includeEpisodes, false)
)
+ .option(
+ "--no-include-non-videos",
+ "Don't include torrents which contain non-videos"
+ )
+ .option("--no-include-episodes", "Don't include episode torrents")
.requiredOption(
"--fuzzy-size-threshold ",
"The size difference allowed to be considered a match.",
@@ -118,6 +123,11 @@ function createCommandWithSharedOptions(name, description) {
"The url of your qBittorrent webui. Requires '-A inject'. See the docs for more information.",
fileConfig.qbittorrentUrl
)
+ .option(
+ "--transmission-rpc-url ",
+ "The url of your Transmission RPC interface. Requires '-A inject'. See the docs for more information.",
+ fileConfig.transmissionRpcUrl
+ )
.option(
"--duplicate-categories",
"Create and inject using categories with the same save paths as your normal categories",
@@ -160,6 +170,7 @@ program
.description("Clear the cache of downloaded-and-rejected torrents")
.action(async () => {
await db("decision").del();
+ await db.destroy();
});
program
@@ -201,6 +212,7 @@ createCommandWithSharedOptions("daemon", "Start the cross-seed daemon")
(n) => parseInt(n),
fallback(fileConfig.port, 2468)
)
+ .option("--host ", "Bind to a specific IP address", fileConfig.host)
.option("--no-port", "Do not listen on any port")
.option(
"--search-cadence ",
@@ -224,7 +236,7 @@ createCommandWithSharedOptions("daemon", "Start the cross-seed daemon")
});
await db.migrate.latest();
await doStartupValidation();
- serve(options.port);
+ serve(options.port, options.host);
jobsLoop();
} catch (e) {
exitOnCrossSeedErrors(e);
@@ -272,7 +284,6 @@ createCommandWithSharedOptions("search", "Search for cross-seeds")
label: Label.CONFIGDUMP,
message: inspect(runtimeConfig),
});
-
await db.migrate.latest();
await doStartupValidation();
await main();
diff --git a/src/config.template.cjs b/src/config.template.cjs
index 8756af3ab..cb992c70f 100644
--- a/src/config.template.cjs
+++ b/src/config.template.cjs
@@ -88,6 +88,15 @@ module.exports = {
*/
qbittorrentUrl: undefined,
+ /**
+ * The url of your Transmission RPC interface.
+ * Usually ends with "/transmission/rpc".
+ * Only relevant with action: "inject".
+ * Supply your username and password inside the url like so:
+ * "http://username:password@localhost:9091/transmission/rpc"
+ */
+ transmissionRpcUrl: undefined,
+
/**
* qBittorrent-specific
* Whether to inject using categories with the same save paths as your normal categories.
@@ -108,6 +117,12 @@ module.exports = {
*/
port: 2468,
+ /**
+ * Bind to a specific host address.
+ * Example: "127.0.0.1"
+ */
+ host: undefined,
+
/**
* Run rss scans on a schedule. Format: https://github.com/vercel/ms
* Set to undefined or null to disable. Minimum of 10 minutes.
diff --git a/src/config.template.docker.cjs b/src/config.template.docker.cjs
index 9319de875..9df4fee33 100644
--- a/src/config.template.docker.cjs
+++ b/src/config.template.docker.cjs
@@ -93,6 +93,15 @@ module.exports = {
*/
qbittorrentUrl: undefined,
+ /**
+ * The url of your Transmission RPC interface.
+ * Usually ends with "/transmission/rpc".
+ * Only relevant with action: "inject".
+ * Supply your username and password inside the url like so:
+ * "http://username:password@localhost:9091/transmission/rpc"
+ */
+ transmissionRpcUrl: undefined,
+
/**
* qBittorrent-specific
* Whether to inject using categories with the same save paths as your normal categories.
@@ -101,7 +110,6 @@ module.exports = {
*/
duplicateCategories: false,
-
/**
* cross-seed will send POST requests to this url
* with a JSON payload of { title, body }.
@@ -114,6 +122,12 @@ module.exports = {
*/
port: 2468,
+ /**
+ * Bind to a specific host address.
+ * Example: "127.0.0.1"
+ */
+ host: undefined,
+
/**
* Run rss scans on a schedule. Format: https://github.com/vercel/ms
* Set to undefined or null to disable. Minimum of 10 minutes.
@@ -134,5 +148,4 @@ module.exports = {
* "3 days"
*/
searchCadence: undefined,
-
};
diff --git a/src/configuration.ts b/src/configuration.ts
index 743342864..7e15df292 100644
--- a/src/configuration.ts
+++ b/src/configuration.ts
@@ -22,9 +22,11 @@ interface FileConfig {
torrentDir?: string;
torznab?: string[];
qbittorrentUrl?: string;
+ transmissionRpcUrl?: string;
duplicateCategories?: boolean;
notificationWebhookUrl?: string;
port?: number;
+ host?: string;
searchCadence?: string;
rssCadence?: string;
}
diff --git a/src/constants.ts b/src/constants.ts
index 172fe7fe7..d77a3ed74 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -31,6 +31,7 @@ export enum Decision {
SIZE_MISMATCH = "SIZE_MISMATCH",
NO_DOWNLOAD_LINK = "NO_DOWNLOAD_LINK",
DOWNLOAD_FAILED = "DOWNLOAD_FAILED",
+ RATE_LIMITED = "RATE_LIMITED",
INFO_HASH_ALREADY_EXISTS = "INFO_HASH_ALREADY_EXISTS",
FILE_TREE_MISMATCH = "FILE_TREE_MISMATCH",
}
diff --git a/src/db.ts b/src/db.ts
index a3e4f0564..ca10e7a89 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -2,11 +2,16 @@ import Knex from "knex";
import { join } from "path";
import { appDir } from "./configuration.js";
import { migrations } from "./migrations/migrations.js";
+import Sqlite from "better-sqlite3";
+
+const filename = join(appDir(), "cross-seed.db");
+const rawSqliteHandle = new Sqlite(filename);
+rawSqliteHandle.pragma("journal_mode = WAL");
+rawSqliteHandle.close();
export const db = Knex.knex({
client: "better-sqlite3",
- connection: { filename: join(appDir(), "cross-seed.db") },
- migrations: { migrationSource: migrations, disableTransactions: true },
+ connection: { filename },
+ migrations: { migrationSource: migrations },
useNullAsDefault: true,
- acquireConnectionTimeout: 5000,
});
diff --git a/src/decide.ts b/src/decide.ts
index 02ab466cf..62178a8fc 100644
--- a/src/decide.ts
+++ b/src/decide.ts
@@ -1,15 +1,19 @@
import { existsSync, writeFileSync } from "fs";
-import parseTorrent, { FileListing, Metafile } from "parse-torrent";
+import parseTorrent, { Metafile } from "parse-torrent";
import path from "path";
import { appDir } from "./configuration.js";
import { Decision, TORRENT_CACHE_FOLDER } from "./constants.js";
+import { db } from "./db.js";
import { Label, logger } from "./logger.js";
import { Candidate } from "./pipeline.js";
import { getRuntimeConfig } from "./runtimeConfig.js";
-import { Searchee } from "./searchee.js";
-import { db } from "./db.js";
-import { parseTorrentFromFilename, parseTorrentFromURL } from "./torrent.js";
-import { File } from "./searchee.js";
+import { File, getFiles, Searchee } from "./searchee.js";
+import {
+ parseTorrentFromFilename,
+ parseTorrentFromURL,
+ SnatchError,
+} from "./torrent.js";
+
export interface ResultAssessment {
decision: Decision;
metafile?: Metafile;
@@ -34,6 +38,9 @@ const createReasonLogger =
case Decision.NO_DOWNLOAD_LINK:
reason = "it doesn't have a download link";
break;
+ case Decision.RATE_LIMITED:
+ reason = "cross-seed has reached this tracker's rate limit";
+ break;
case Decision.DOWNLOAD_FAILED:
reason = "the torrent file failed to download";
break;
@@ -55,14 +62,14 @@ export function compareFileTrees(
candidate: Metafile,
searchee: Searchee
): boolean {
- const cmp = (elOfA: FileListing, elOfB: File) => {
+ const cmp = (elOfA: File, elOfB: File) => {
const lengthsAreEqual = elOfB.length === elOfA.length;
const pathsAreEqual = elOfB.path === elOfA.path;
return lengthsAreEqual && pathsAreEqual;
};
- return candidate.files.every((elOfA) =>
+ return getFiles(candidate).every((elOfA) =>
searchee.files.some((elOfB) => cmp(elOfA, elOfB))
);
}
@@ -87,19 +94,25 @@ async function assessCandidateHelper(
if (!link) return { decision: Decision.NO_DOWNLOAD_LINK };
- const info = await parseTorrentFromURL(link);
+ const result = await parseTorrentFromURL(link);
+
+ if (result.isErr()) {
+ return result.unwrapErrOrThrow() === SnatchError.RATE_LIMITED
+ ? { decision: Decision.RATE_LIMITED }
+ : { decision: Decision.DOWNLOAD_FAILED };
+ }
- if (!info) return { decision: Decision.DOWNLOAD_FAILED };
+ const candidateMeta = result.unwrapOrThrow();
- if (hashesToExclude.includes(info.infoHash)) {
+ if (hashesToExclude.includes(candidateMeta.infoHash)) {
return { decision: Decision.INFO_HASH_ALREADY_EXISTS };
}
- if (!compareFileTrees(info, searchee)) {
+ if (!compareFileTrees(candidateMeta, searchee)) {
return { decision: Decision.FILE_TREE_MISMATCH };
}
- return { decision: Decision.MATCH, metafile: info };
+ return { decision: Decision.MATCH, metafile: candidateMeta };
}
function existsInTorrentCache(infoHash: string): boolean {
@@ -171,7 +184,11 @@ async function assessCandidateCaching(
const logReason = createReasonLogger(name, tracker, searchee.name);
const cacheEntry = await db("decision")
- .select("decision.*")
+ .select({
+ decision: "decision.decision",
+ infoHash: "decision.info_hash",
+ id: "decision.id",
+ })
.join("searchee", "decision.searchee_id", "searchee.id")
.where({ name: searchee.name, guid })
.first();
@@ -179,7 +196,8 @@ async function assessCandidateCaching(
if (
!cacheEntry?.decision ||
- cacheEntry.decision === Decision.DOWNLOAD_FAILED
+ cacheEntry.decision === Decision.DOWNLOAD_FAILED ||
+ cacheEntry.decision === Decision.RATE_LIMITED
) {
assessment = await assessAndSaveResults(
candidate,
@@ -199,12 +217,12 @@ async function assessCandidateCaching(
.update({ decision: Decision.INFO_HASH_ALREADY_EXISTS });
} else if (
cacheEntry.decision === Decision.MATCH &&
- existsInTorrentCache(cacheEntry.info_hash)
+ existsInTorrentCache(cacheEntry.infoHash)
) {
// cached match
assessment = {
decision: cacheEntry.decision,
- metafile: await getCachedTorrentFile(cacheEntry.info_hash),
+ metafile: await getCachedTorrentFile(cacheEntry.infoHash),
};
} else if (cacheEntry.decision === Decision.MATCH) {
assessment = await assessAndSaveResults(
diff --git a/src/indexers.ts b/src/indexers.ts
new file mode 100644
index 000000000..c223d1e48
--- /dev/null
+++ b/src/indexers.ts
@@ -0,0 +1,94 @@
+import { db } from "./db.js";
+import { Label, logger } from "./logger.js";
+import { humanReadable } from "./utils.js";
+
+export enum IndexerStatus {
+ /**
+ * equivalent to null
+ */
+ OK = "OK",
+ RATE_LIMITED = "RATE_LIMITED",
+ UNKNOWN_ERROR = "UNKNOWN_ERROR",
+}
+
+export interface Indexer {
+ id: number;
+ url: string;
+ apikey: string;
+ /**
+ * Whether the indexer is currently specified in config
+ */
+ active: boolean;
+ status: IndexerStatus;
+ retryAfter: number;
+ searchCap: boolean;
+ tvSearchCap: boolean;
+ movieSearchCap: boolean;
+}
+
+export async function getEnabledIndexers() {
+ return db("indexer")
+ .where({ active: true, search_cap: true, status: null })
+ .orWhere({ active: true, search_cap: true, status: IndexerStatus.OK })
+ .orWhere((b) =>
+ b
+ .where({ active: true, search_cap: true })
+ .where("retry_after", "<", Date.now())
+ )
+ .select({
+ id: "id",
+ url: "url",
+ apikey: "apikey",
+ active: "active",
+ status: "status",
+ retryAfter: "retry_after",
+ searchCap: "search_cap",
+ tvSearchCap: "tv_search_cap",
+ movieSearchCap: "movie_search_cap",
+ });
+}
+
+export async function updateIndexerStatus(
+ status: IndexerStatus,
+ retryAfter: number,
+ indexerIds: number[]
+) {
+ if (indexerIds.length > 0) {
+ logger.verbose({
+ label: Label.TORZNAB,
+ message: `Snoozing indexers ${indexerIds} with ${status} until ${humanReadable(
+ retryAfter
+ )}`,
+ });
+
+ await db("indexer").whereIn("id", indexerIds).update({
+ retry_after: retryAfter,
+ status,
+ });
+ }
+}
+
+export async function updateSearchTimestamps(
+ name: string,
+ indexerIds: number[]
+) {
+ for (const indexerId of indexerIds) {
+ await db.transaction(async (trx) => {
+ const now = Date.now();
+ const { id: searchee_id } = await trx("searchee")
+ .where({ name })
+ .select("id")
+ .first();
+
+ await trx("timestamp")
+ .insert({
+ searchee_id,
+ indexer_id: indexerId,
+ last_searched: now,
+ first_searched: now,
+ })
+ .onConflict(["searchee_id", "indexer_id"])
+ .merge(["searchee_id", "indexer_id", "last_searched"]);
+ });
+ }
+}
diff --git a/src/jobs.ts b/src/jobs.ts
index b2f5ff1e9..de942ea83 100644
--- a/src/jobs.ts
+++ b/src/jobs.ts
@@ -17,7 +17,7 @@ class Job {
this.isActive = false;
}
- async run() {
+ async run(): Promise {
if (!this.isActive) {
this.isActive = true;
try {
@@ -29,7 +29,9 @@ class Job {
} finally {
this.isActive = false;
}
+ return true;
}
+ return false;
}
}
@@ -41,7 +43,7 @@ const getJobs = () => {
].filter(Boolean);
};
-export async function jobsLoop() {
+export function jobsLoop() {
const jobs = getJobs();
async function loop() {
@@ -58,19 +60,22 @@ export async function jobsLoop() {
const eligibilityTs = lastRun ? lastRun + job.cadence : now;
const lastRunStr = lastRun ? `${ms(now - lastRun)} ago` : "never";
const nextRunStr = ms(eligibilityTs - now);
- logger.verbose({
+
+ logger.info({
label: Label.SCHEDULER,
message: `${job.name}: last run ${lastRunStr}, next run in ${nextRunStr}`,
});
if (now >= eligibilityTs) {
job.run()
- .then(async () => {
- // upon success, update the log
- await db("job_log")
- .insert({ name: job.name, last_run: now })
- .onConflict("name")
- .merge();
+ .then(async (didRun) => {
+ if (didRun) {
+ // upon success, update the log
+ await db("job_log")
+ .insert({ name: job.name, last_run: now })
+ .onConflict("name")
+ .merge();
+ }
})
.catch(exitOnCrossSeedErrors)
.catch((e) => void logger.error(e));
diff --git a/src/logger.ts b/src/logger.ts
index f4e571daa..408b501c5 100644
--- a/src/logger.ts
+++ b/src/logger.ts
@@ -7,6 +7,7 @@ import DailyRotateFile from "winston-daily-rotate-file";
export enum Label {
QBITTORRENT = "qbittorrent",
RTORRENT = "rtorrent",
+ TRANSMISSION = "transmission",
DECIDE = "decide",
PREFILTER = "prefilter",
CONFIGDUMP = "configdump",
@@ -46,7 +47,10 @@ function redactMessage(message) {
// redact torznab api keys
message = message.replace(/apikey=[a-zA-Z0-9]+/g, `apikey=${redactionMsg}`);
-
+ message = message.replace(
+ /\/notification\/crossSeed\/\w+/g,
+ `/notification/crossSeed/${redactionMsg}`
+ );
for (const [key, urlStr] of Object.entries(runtimeConfig)) {
if (key.endsWith("Url") && urlStr) {
message = redactUrlPassword(message, urlStr);
diff --git a/src/migrations/00-initialSchema.ts b/src/migrations/00-initialSchema.ts
index 754d06120..e3b2bff6d 100644
--- a/src/migrations/00-initialSchema.ts
+++ b/src/migrations/00-initialSchema.ts
@@ -81,9 +81,4 @@ async function down(knex: Knex.Knex): Promise {
await knex.schema.dropTable("torrent");
}
-export default {
- name: "00-initialSchema",
- up,
- down,
- config: { transaction: true },
-};
+export default { name: "00-initialSchema", up, down };
diff --git a/src/migrations/01-jobs.ts b/src/migrations/01-jobs.ts
index 7a79797da..451063dda 100644
--- a/src/migrations/01-jobs.ts
+++ b/src/migrations/01-jobs.ts
@@ -1,12 +1,6 @@
import Knex from "knex";
-import { join } from "path";
-import { appDir } from "../configuration.js";
async function up(knex: Knex.Knex): Promise {
- const connection = await knex.client.acquireConnection();
- await connection.backup(join(appDir(), "cross-seed.pre-jobs.backup.db"));
- await knex.client.releaseConnection(connection);
-
await knex.schema.createTable("job_log", (table) => {
table.increments("id").primary();
table.string("name").unique();
diff --git a/src/migrations/02-timestamps.ts b/src/migrations/02-timestamps.ts
new file mode 100644
index 000000000..be098b2d4
--- /dev/null
+++ b/src/migrations/02-timestamps.ts
@@ -0,0 +1,63 @@
+import Knex from "knex";
+import { getRuntimeConfig } from "../runtimeConfig.js";
+
+function sanitizeUrl(url: string | URL): string {
+ url = new URL(url);
+ return url.origin + url.pathname;
+}
+
+function getApikey(url: string) {
+ return new URL(url).searchParams.get("apikey");
+}
+
+async function backfill(knex: Knex.Knex) {
+ const { torznab } = getRuntimeConfig();
+
+ await knex("indexer")
+ .insert(
+ torznab.map((url) => ({
+ url: sanitizeUrl(url),
+ apikey: getApikey(url),
+ active: true,
+ }))
+ )
+ .onConflict("url")
+ .merge(["active", "apikey"]);
+
+ const timestampRows = await knex
+ .select(
+ "searchee.id as searchee_id",
+ "indexer.id as indexer_id",
+ "searchee.first_searched as first_searched",
+ "searchee.last_searched as last_searched"
+ )
+ .from("searchee")
+ .crossJoin(knex.raw("indexer"));
+ await knex.batchInsert("timestamp", timestampRows, 100);
+}
+
+async function up(knex: Knex.Knex): Promise {
+ await knex.schema.createTable("indexer", (table) => {
+ table.increments("id").primary();
+ table.string("url").unique();
+ table.string("apikey");
+ table.boolean("active");
+ });
+
+ await knex.schema.createTable("timestamp", (table) => {
+ table.integer("searchee_id").references("id").inTable("searchee");
+ table.integer("indexer_id").references("id").inTable("indexer");
+ table.integer("first_searched");
+ table.integer("last_searched");
+ table.primary(["searchee_id", "indexer_id"]);
+ });
+
+ await backfill(knex);
+}
+
+async function down(knex: Knex.Knex): Promise {
+ await knex.schema.dropTable("timestamp");
+ await knex.schema.dropTable("indexer");
+}
+
+export default { name: "02-timestamps", up, down };
diff --git a/src/migrations/03-rateLimits.ts b/src/migrations/03-rateLimits.ts
new file mode 100644
index 000000000..a1f23e7a5
--- /dev/null
+++ b/src/migrations/03-rateLimits.ts
@@ -0,0 +1,17 @@
+import Knex from "knex";
+
+async function up(knex: Knex.Knex): Promise {
+ await knex.schema.alterTable("indexer", (table) => {
+ table.string("status");
+ table.integer("retry_after");
+ table.boolean("search_cap").nullable();
+ table.boolean("tv_search_cap").nullable();
+ table.boolean("movie_search_cap").nullable();
+ });
+}
+
+function down(): void {
+ // no new tables created
+}
+
+export default { name: "03-rateLimits", up, down };
diff --git a/src/migrations/migrations.ts b/src/migrations/migrations.ts
index 8c69f1447..5489e716d 100644
--- a/src/migrations/migrations.ts
+++ b/src/migrations/migrations.ts
@@ -1,10 +1,11 @@
import initialSchema from "./00-initialSchema.js";
import jobs from "./01-jobs.js";
-
-// The first step of any migration should be to back up the database.
+import timestamps from "./02-timestamps.js";
+import rateLimits from "./03-rateLimits.js";
export const migrations = {
- getMigrations: () => Promise.resolve([initialSchema, jobs]),
+ getMigrations: () =>
+ Promise.resolve([initialSchema, jobs, timestamps, rateLimits]),
getMigrationName: (migration) => migration.name,
getMigration: (migration) => migration,
};
diff --git a/src/pipeline.ts b/src/pipeline.ts
index 486933384..2a46b7b67 100755
--- a/src/pipeline.ts
+++ b/src/pipeline.ts
@@ -1,6 +1,7 @@
import chalk from "chalk";
import fs from "fs";
import { zip } from "lodash-es";
+import ms from "ms";
import { performAction, performActions } from "./action.js";
import {
ActionResult,
@@ -10,6 +11,11 @@ import {
} from "./constants.js";
import { db } from "./db.js";
import { assessCandidate, ResultAssessment } from "./decide.js";
+import {
+ IndexerStatus,
+ updateIndexerStatus,
+ updateSearchTimestamps,
+} from "./indexers.js";
import { Label, logger } from "./logger.js";
import { filterByContent, filterDupes, filterTimestamps } from "./preFilter.js";
import { sendResultsNotification } from "./pushNotifier.js";
@@ -31,8 +37,8 @@ import {
loadTorrentDirLight,
TorrentLocator,
} from "./torrent.js";
-import { getTorznabManager } from "./torznab.js";
-import { filterAsync, ok, stripExtension } from "./utils.js";
+import { queryRssFeeds, searchTorznab } from "./torznab.js";
+import { filterAsync, stripExtension } from "./utils.js";
export interface Candidate {
guid: string;
@@ -40,6 +46,8 @@ export interface Candidate {
size: number;
name: string;
tracker: string;
+ pubDate: number;
+ indexerId?: number;
}
interface AssessmentWithTracker {
@@ -59,27 +67,56 @@ async function findOnOtherSites(
tracker: result.tracker,
});
- const query = stripExtension(searchee.name);
-
// make sure searchee is in database
await db("searchee")
.insert({ name: searchee.name })
.onConflict("name")
.ignore();
- let response: Candidate[];
+ let response: { indexerId: number; candidates: Candidate[] }[];
try {
- response = await getTorznabManager().searchTorznab(query, nonceOptions);
+ response = await searchTorznab(searchee.name);
} catch (e) {
- logger.error(`error searching for ${query}`);
+ logger.error(`error searching for ${searchee.name}`);
+ logger.debug(e);
return 0;
}
- const results = response;
- const loaded = await Promise.all(
+ const results: Candidate[] = response.flatMap((e) =>
+ e.candidates.map((candidate) => ({
+ ...candidate,
+ indexerId: e.indexerId,
+ }))
+ );
+
+ const assessed = await Promise.all(
results.map(assessEach)
);
- const matches = loaded.filter(
+
+ const { rateLimited, notRateLimited } = assessed.reduce(
+ (acc, cur, idx) => {
+ const candidate = results[idx];
+ if (cur.assessment.decision === Decision.RATE_LIMITED) {
+ acc.rateLimited.add(candidate.indexerId);
+ acc.notRateLimited.delete(candidate.indexerId);
+ }
+ return acc;
+ },
+ {
+ rateLimited: new Set(),
+ notRateLimited: new Set(response.map((r) => r.indexerId)),
+ }
+ );
+
+ await updateSearchTimestamps(searchee.name, Array.from(notRateLimited));
+
+ await updateIndexerStatus(
+ IndexerStatus.RATE_LIMITED,
+ Date.now() + ms("1 hour"),
+ Array.from(rateLimited)
+ );
+
+ const matches = assessed.filter(
(e) => e.assessment.decision === Decision.MATCH
);
const actionResults = await performActions(searchee, matches, nonceOptions);
@@ -91,25 +128,10 @@ async function findOnOtherSites(
actionResults
);
sendResultsNotification(searchee, zipped, Label.SEARCH);
- await updateSearchTimestamps(searchee.name);
}
return matches.length;
}
-async function updateSearchTimestamps(name: string): Promise {
- await db.transaction(async (trx) => {
- const now = Date.now();
- const entry = await trx("searchee").where({ name }).first();
-
- await trx("searchee")
- .where({ name })
- .update({
- last_searched: now,
- first_searched: entry?.first_searched ? undefined : now,
- });
- });
-}
-
async function findMatchesBatch(
samples: Searchee[],
hashesToExclude: string[]
@@ -192,7 +214,9 @@ async function findSearchableTorrents() {
const searcheeResults = await Promise.all(
torrents.map(createSearcheeFromTorrentFile)
);
- parsedTorrents = searcheeResults.filter(ok);
+ parsedTorrents = searcheeResults
+ .filter((t) => t.isOk())
+ .map((t) => t.unwrapOrThrow());
} else {
parsedTorrents = await loadTorrentDirLight();
}
@@ -233,20 +257,32 @@ export async function main(): Promise {
}
export async function scanRssFeeds() {
- const candidates = await getTorznabManager().searchTorznab("");
+ const candidates = await queryRssFeeds();
+ const lastRun =
+ (await db("job_log").select("last_run").where({ name: "rss" }).first())
+ ?.last_run ?? 0;
+ const candidatesSinceLastTime = candidates.filter(
+ (c) => c.pubDate > lastRun
+ );
logger.verbose({
label: Label.RSS,
- message: `Scan returned ${candidates.length} results`,
+ message: `Scan returned ${
+ candidatesSinceLastTime.length
+ } new results, ignoring ${
+ candidates.length - candidatesSinceLastTime.length
+ } already seen`,
});
logger.verbose({
label: Label.RSS,
message: "Indexing new torrents...",
});
await indexNewTorrents();
- for (const [i, candidate] of candidates.entries()) {
+ for (const [i, candidate] of candidatesSinceLastTime.entries()) {
logger.verbose({
label: Label.RSS,
- message: `Processing release ${i + 1}/${candidates.length}`,
+ message: `Processing release ${i + 1}/${
+ candidatesSinceLastTime.length
+ }`,
});
await checkNewCandidateMatch(candidate);
}
diff --git a/src/preFilter.ts b/src/preFilter.ts
index 4d40391cb..f934c59c6 100644
--- a/src/preFilter.ts
+++ b/src/preFilter.ts
@@ -1,11 +1,13 @@
import { uniqBy } from "lodash-es";
+import ms from "ms";
import path from "path";
import { EP_REGEX, EXTENSIONS } from "./constants.js";
+import { db } from "./db.js";
+import { getEnabledIndexers } from "./indexers.js";
import { Label, logger } from "./logger.js";
import { getRuntimeConfig } from "./runtimeConfig.js";
import { Searchee } from "./searchee.js";
-import { db } from "./db.js";
-import { nMsAgo } from "./utils.js";
+import { humanReadable, nMsAgo } from "./utils.js";
const extensionsWithDots = EXTENSIONS.map((e) => `.${e}`);
@@ -53,13 +55,20 @@ export function filterDupes(searchees: Searchee[]): Searchee[] {
export async function filterTimestamps(searchee: Searchee): Promise {
const { excludeOlder, excludeRecentSearch } = getRuntimeConfig();
-
const timestampDataSql = await db("searchee")
- .where({ name: searchee.name })
+ .join("timestamp", "searchee.id", "timestamp.searchee_id")
+ .join("indexer", "timestamp.indexer_id", "indexer.id")
+ .where({
+ name: searchee.name,
+ "indexer.active": true,
+ "indexer.search_cap": true,
+ })
+ .max({ first_searched_all: "timestamp.first_searched" })
+ .min({ last_searched_all: "timestamp.last_searched" })
.first();
if (!timestampDataSql) return true;
- const { first_searched, last_searched } = timestampDataSql;
+ const { first_searched_all, last_searched_all } = timestampDataSql;
function logReason(reason) {
logger.verbose({
label: Label.PREFILTER,
@@ -68,23 +77,27 @@ export async function filterTimestamps(searchee: Searchee): Promise {
}
if (
- excludeOlder &&
- first_searched &&
- first_searched < nMsAgo(excludeOlder)
+ typeof excludeOlder === "number" &&
+ first_searched_all &&
+ first_searched_all < nMsAgo(excludeOlder)
) {
logReason(
- `its first search timestamp ${first_searched} is older than ${excludeOlder} minutes ago`
+ `its first search timestamp ${humanReadable(
+ first_searched_all
+ )} is older than ${ms(excludeOlder, { long: true })} ago`
);
return false;
}
if (
- excludeRecentSearch &&
- last_searched &&
- last_searched > nMsAgo(excludeRecentSearch)
+ typeof excludeRecentSearch === "number" &&
+ last_searched_all &&
+ last_searched_all > nMsAgo(excludeRecentSearch)
) {
logReason(
- `its last search timestamp ${last_searched} is newer than ${excludeRecentSearch} minutes ago`
+ `its last search timestamp ${humanReadable(
+ last_searched_all
+ )} is newer than ${ms(excludeRecentSearch, { long: true })} ago`
);
return false;
}
diff --git a/src/pushNotifier.ts b/src/pushNotifier.ts
index ad40fd7ba..6e6490031 100644
--- a/src/pushNotifier.ts
+++ b/src/pushNotifier.ts
@@ -4,6 +4,7 @@ import { ResultAssessment } from "./decide.js";
import { Label, logger } from "./logger.js";
import { getRuntimeConfig } from "./runtimeConfig.js";
import { Searchee } from "./searchee.js";
+import { formatAsList } from "./utils.js";
export let pushNotifier: PushNotifier;
enum Event {
@@ -36,14 +37,6 @@ export class PushNotifier {
}
}
-function formatTrackersAsList(trackers: TrackerName[]) {
- // @ts-expect-error Intl.ListFormat totally exists on node 12
- return new Intl.ListFormat("en", {
- style: "long",
- type: "conjunction",
- }).format(trackers);
-}
-
export function sendResultsNotification(
searchee: Searchee,
results: [ResultAssessment, TrackerName, ActionResult][],
@@ -64,7 +57,7 @@ export function sendResultsNotification(
([assessment]) => assessment.metafile.infoHash
);
const trackers = notableSuccesses.map(([, tracker]) => tracker);
- const trackersListStr = formatTrackersAsList(trackers);
+ const trackersListStr = formatAsList(trackers);
const performedAction =
notableSuccesses[0][2] === InjectionResult.SUCCESS
? "Injected"
@@ -88,7 +81,8 @@ export function sendResultsNotification(
([assessment]) => assessment.metafile.infoHash
);
const trackers = failures.map(([, tracker]) => tracker);
- const trackersListStr = formatTrackersAsList(trackers);
+ const trackersListStr = formatAsList(trackers);
+
pushNotifier.notify({
body: `Failed to inject ${name} from ${numTrackers} trackers: ${trackersListStr}`,
extra: {
diff --git a/src/runtimeConfig.ts b/src/runtimeConfig.ts
index 001d2de55..fb1a3f7b7 100644
--- a/src/runtimeConfig.ts
+++ b/src/runtimeConfig.ts
@@ -14,6 +14,7 @@ export interface RuntimeConfig {
action: Action;
rtorrentRpcUrl: string;
qbittorrentUrl: string;
+ transmissionRpcUrl: string;
duplicateCategories: boolean;
notificationWebhookUrl: string;
torrents: string[];
diff --git a/src/searchee.ts b/src/searchee.ts
index 61fdfebe9..804819f9d 100644
--- a/src/searchee.ts
+++ b/src/searchee.ts
@@ -2,8 +2,8 @@ import { sortBy } from "lodash-es";
import { Metafile } from "parse-torrent";
import { basename, sep as osSpecificPathSeparator } from "path";
import { parseTorrentFromFilename } from "./torrent.js";
-import { Result } from "./utils.js";
import { logger } from "./logger.js";
+import { Result, resultOf, resultOfErr } from "./Result.js";
export interface File {
length: number;
@@ -19,7 +19,7 @@ export interface Searchee {
length: number;
}
-function getFilesFromTorrent(meta: Metafile): File[] {
+export function getFiles(meta: Metafile): File[] {
if (!meta.info.files) {
return [
{
@@ -49,7 +49,7 @@ function getFilesFromTorrent(meta: Metafile): File[] {
export function createSearcheeFromMetafile(meta: Metafile): Searchee {
return {
- files: getFilesFromTorrent(meta),
+ files: getFiles(meta),
infoHash: meta.infoHash,
name: meta.name,
length: meta.length,
@@ -58,13 +58,13 @@ export function createSearcheeFromMetafile(meta: Metafile): Searchee {
export async function createSearcheeFromTorrentFile(
filepath: string
-): Promise> {
+): Promise> {
try {
const meta = await parseTorrentFromFilename(filepath);
- return createSearcheeFromMetafile(meta);
+ return resultOf(createSearcheeFromMetafile(meta));
} catch (e) {
logger.error(`Failed to parse ${basename(filepath)}`);
logger.debug(e);
- return e;
+ return resultOfErr(e);
}
}
diff --git a/src/server.ts b/src/server.ts
index feaff2425..9d387a075 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -71,6 +71,7 @@ async function search(
logger.error({ label: Label.SERVER, message });
res.writeHead(400);
res.end(message);
+ return;
}
const criteriaStr = inspect(criteria);
@@ -209,10 +210,10 @@ async function handleRequest(
}
}
-export async function serve(port: number): Promise {
+export function serve(port: number, host: string | undefined): void {
if (port) {
const server = http.createServer(handleRequest);
- server.listen(port);
+ server.listen(port, host);
logger.info({
label: Label.SERVER,
message: `Server is running on port ${port}, ^C to stop.`,
diff --git a/src/startup.ts b/src/startup.ts
index 0539a0ecd..4a6f3c3f7 100644
--- a/src/startup.ts
+++ b/src/startup.ts
@@ -3,13 +3,17 @@ import { CrossSeedError } from "./errors.js";
import { logger } from "./logger.js";
import { getRuntimeConfig } from "./runtimeConfig.js";
import { validateTorrentDir } from "./torrent.js";
-import { getTorznabManager, TorznabManager } from "./torznab.js";
+import { validateTorznabUrls } from "./torznab.js";
function validateOptions() {
- const { action, rtorrentRpcUrl, qbittorrentUrl } = getRuntimeConfig();
- if (action === "inject" && !(rtorrentRpcUrl || qbittorrentUrl)) {
+ const { action, rtorrentRpcUrl, qbittorrentUrl, transmissionRpcUrl } =
+ getRuntimeConfig();
+ if (
+ action === "inject" &&
+ !(rtorrentRpcUrl || qbittorrentUrl || transmissionRpcUrl)
+ ) {
throw new CrossSeedError(
- "You need to specify --rtorrent-rpc-url or --qbittorrent-url when using '-A inject'."
+ "You need to specify --rtorrent-rpc-url, --transmission-rpc-url, or --qbittorrent-url when using '-A inject'."
);
}
}
@@ -18,10 +22,9 @@ export async function doStartupValidation(): Promise {
logger.info("Validating your configuration...");
validateOptions();
const downloadClient = getClient();
- const torznabManager = getTorznabManager();
await Promise.all(
[
- torznabManager.validateTorznabUrls(),
+ validateTorznabUrls(),
downloadClient?.validateConfig(),
validateTorrentDir(),
].filter(Boolean)
diff --git a/src/torrent.ts b/src/torrent.ts
index 68d51ba2e..aa9314862 100644
--- a/src/torrent.ts
+++ b/src/torrent.ts
@@ -2,20 +2,29 @@ import fs, { promises as fsPromises } from "fs";
import Fuse from "fuse.js";
import parseTorrent, { Metafile } from "parse-torrent";
import path, { join } from "path";
-import simpleGet from "simple-get";
import { inspect } from "util";
import { db } from "./db.js";
import { CrossSeedError } from "./errors.js";
import { logger, logOnce } from "./logger.js";
+import { Result, resultOf, resultOfErr } from "./Result.js";
import { getRuntimeConfig, NonceOptions } from "./runtimeConfig.js";
import { createSearcheeFromTorrentFile, Searchee } from "./searchee.js";
-import { ok, stripExtension } from "./utils.js";
+import { stripExtension } from "./utils.js";
+import fetch, { Response } from "node-fetch";
export interface TorrentLocator {
infoHash?: string;
name?: string;
}
+export enum SnatchError {
+ ABORTED = "ABORTED",
+ RATE_LIMITED = "RATE_LIMITED",
+ MAGNET_LINK = "MAGNET_LINK",
+ INVALID_CONTENTS = "INVALID_CONTENTS",
+ UNKNOWN_ERROR = "UNKNOWN_ERROR",
+}
+
export async function parseTorrentFromFilename(
filename: string
): Promise {
@@ -23,49 +32,69 @@ export async function parseTorrentFromFilename(
return parseTorrent(data);
}
-export async function parseTorrentFromURL(url: string): Promise {
- let response;
+export async function parseTorrentFromURL(
+ url: string
+): Promise> {
+ const abortController = new AbortController();
+ setTimeout(() => void abortController.abort(), 30000).unref();
+
+ let response: Response;
try {
- response = await new Promise((resolve, reject) => {
- simpleGet.concat(
- { url, followRedirects: false },
- (err, res, data) => {
- if (err) return reject(err);
- res.data = data;
- return resolve(res);
- }
- );
+ response = await fetch(url, {
+ headers: { "User-Agent": "cross-seed" },
+ signal: abortController.signal,
+ redirect: "manual",
});
} catch (e) {
+ if (e.name === "AbortError") {
+ logger.error(`snatching ${url} timed out`);
+ return resultOfErr(SnatchError.ABORTED);
+ }
logger.error(`failed to access ${url}`);
logger.debug(e);
- return null;
+ return resultOfErr(SnatchError.UNKNOWN_ERROR);
}
- if (response.statusCode < 200 || response.statusCode >= 300) {
- if (
- response.statusCode >= 300 &&
- response.statusCode < 400 &&
- response.headers.location?.startsWith("magnet:")
- ) {
- logger.error(`Unsupported: magnet link detected at ${url}`);
- return null;
- } else {
- logger.error(
- `error downloading torrent at ${url}: ${response.statusCode} ${response.statusMessage}`
- );
- logger.debug("response: %s", response.data);
- logger.debug("headers: %s", response.headers);
- return null;
+ if (
+ response.status.toString().startsWith("3") &&
+ response.headers.get("location")?.startsWith("magnet:")
+ ) {
+ logger.error(`Unsupported: magnet link detected at ${url}`);
+ return resultOfErr(SnatchError.MAGNET_LINK);
+ } else if (response.status === 429) {
+ return resultOfErr(SnatchError.RATE_LIMITED);
+ } else if (!response.ok) {
+ logger.error(
+ `error downloading torrent at ${url}: ${response.status} ${response.statusText}`
+ );
+ logger.debug("response: %s", await response.text());
+ return resultOfErr(SnatchError.UNKNOWN_ERROR);
+ } else if (response.headers.get("Content-Type") === "application/rss+xml") {
+ const responseText = await response.clone().text();
+ if (responseText.includes("429")) {
+ return resultOfErr(SnatchError.RATE_LIMITED);
}
+ logger.error(`invalid torrent contents at ${url}`);
+ logger.debug(
+ `contents: "${responseText.slice(0, 100)}${
+ responseText.length > 100 ? "..." : ""
+ }"`
+ );
+ return resultOfErr(SnatchError.INVALID_CONTENTS);
}
-
try {
- return parseTorrent(response.data);
+ return resultOf(
+ parseTorrent(
+ Buffer.from(new Uint8Array(await response.arrayBuffer()))
+ )
+ );
} catch (e) {
logger.error(`invalid torrent contents at ${url}`);
- logger.debug(e);
- return null;
+ const contentType = response.headers.get("Content-Type");
+ const contentLength = response.headers.get("Content-Length");
+ logger.debug(`Content-Type: ${contentType}`);
+ logger.debug(`Content-Length: ${contentLength}`);
+ return resultOfErr(SnatchError.INVALID_CONTENTS);
}
}
@@ -114,11 +143,14 @@ export async function indexNewTorrents(): Promise {
});
continue;
}
- await db("torrent").insert({
- file_path: filepath,
- info_hash: meta.infoHash,
- name: meta.name,
- });
+ await db("torrent")
+ .insert({
+ file_path: filepath,
+ info_hash: meta.infoHash,
+ name: meta.name,
+ })
+ .onConflict("file_path")
+ .ignore();
}
}
// clean up torrents that no longer exist in the torrentDir
@@ -127,8 +159,9 @@ export async function indexNewTorrents(): Promise {
}
export async function getInfoHashesToExclude(): Promise {
- return (await db("torrent").select("info_hash")).map((t) => t.info_hash);
- // return db.data.indexedTorrents.map((t) => t.infoHash);
+ return (await db("torrent").select({ infoHash: "info_hash" })).map(
+ (t) => t.infoHash
+ );
}
export async function validateTorrentDir(): Promise {
@@ -153,7 +186,9 @@ export async function loadTorrentDirLight(): Promise {
const searcheeResult = await createSearcheeFromTorrentFile(
torrentFilePath
);
- if (ok(searcheeResult)) searchees.push(searcheeResult);
+ if (searcheeResult.isOk()) {
+ searchees.push(searcheeResult.unwrapOrThrow());
+ }
}
return searchees;
}
diff --git a/src/torznab.ts b/src/torznab.ts
index 932554b7d..ac2996c50 100644
--- a/src/torznab.ts
+++ b/src/torznab.ts
@@ -1,16 +1,25 @@
-import { zip } from "lodash-es";
+import ms from "ms";
import fetch from "node-fetch";
import xml2js from "xml2js";
import { EP_REGEX, SEASON_REGEX } from "./constants.js";
+import { db } from "./db.js";
import { CrossSeedError } from "./errors.js";
+import {
+ getEnabledIndexers,
+ Indexer,
+ IndexerStatus,
+ updateIndexerStatus,
+} from "./indexers.js";
import { Label, logger } from "./logger.js";
import { Candidate } from "./pipeline.js";
-import { EmptyNonceOptions, getRuntimeConfig } from "./runtimeConfig.js";
+import { getRuntimeConfig } from "./runtimeConfig.js";
import {
cleanseSeparators,
getTag,
MediaType,
+ nMsAgo,
reformatTitleForSearching,
+ stripExtension,
} from "./utils.js";
interface TorznabParams {
@@ -29,218 +38,435 @@ interface Caps {
movieSearch: boolean;
}
-let activeTorznabManager: TorznabManager;
+type TorznabSearchTechnique = [] | [{ $?: { available: "yes" | "no" } }];
-export class TorznabManager {
- capsMap = new Map();
+type TorznabCaps = {
+ caps?: {
+ searching?: [
+ {
+ search?: TorznabSearchTechnique;
+ "tv-search"?: TorznabSearchTechnique;
+ "movie-search"?: TorznabSearchTechnique;
+ }
+ ];
+ };
+};
- /**
- * Generates a Torznab query URL, given the srcUrl (user config)
- * and the torznab param configuration.
- * @param srcUrl
- * @param params
- */
- assembleUrl(srcUrl: string | URL, params: TorznabParams): string {
- const url = new URL(srcUrl);
- const apikey = url.searchParams.get("apikey");
- const searchParams = new URLSearchParams();
+interface TorznabResult {
+ guid: [string];
+ title: [string];
+ prowlarrindexer?: [{ _: string }];
+ jackettindexer?: [{ _: string }];
+ indexer?: [{ _: string }];
+ link: [string];
+ size: [string];
+ pubDate: [string];
+}
- searchParams.set("apikey", apikey);
+type TorznabResults = { rss?: { channel?: [] | [{ item?: TorznabResult[] }] } };
- for (const [key, value] of Object.entries(params)) {
- if (value != null) searchParams.set(key, value);
- }
+function sanitizeUrl(url: string | URL): string {
+ url = new URL(url);
+ return url.origin + url.pathname;
+}
- url.search = searchParams.toString();
- return url.toString();
+function getApikey(url: string) {
+ return new URL(url).searchParams.get("apikey");
+}
+
+function parseTorznabResults(xml: TorznabResults): Candidate[] {
+ const items = xml?.rss?.channel?.[0]?.item;
+ if (!items || !Array.isArray(items)) {
+ return [];
}
- async validateTorznabUrls() {
- const { torznab } = getRuntimeConfig();
- if (!torznab) return;
+ return items.map((item) => ({
+ guid: item.guid[0],
+ name: item.title[0],
+ tracker:
+ item?.prowlarrindexer?.[0]?._ ??
+ item?.jackettindexer?.[0]?._ ??
+ item?.indexer?.[0]?._ ??
+ "Unknown tracker",
+ link: item.link[0],
+ size: Number(item.size[0]),
+ pubDate: new Date(item.pubDate[0]).getTime(),
+ }));
+}
- const urls: URL[] = torznab.map((str) => new URL(str));
- for (const url of urls) {
- if (!url.pathname.endsWith("/api")) {
- throw new CrossSeedError(
- `Torznab url ${url} must have a path ending in /api`
- );
- }
- if (!url.searchParams.has("apikey")) {
- throw new CrossSeedError(
- `Torznab url ${url} does not specify an apikey`
- );
- }
- }
+function parseTorznabCaps(xml: TorznabCaps): Caps {
+ const capsSection = xml?.caps?.searching?.[0];
+ const isAvailable = (searchTechnique) =>
+ searchTechnique?.[0]?.$?.available === "yes";
+ return {
+ search: Boolean(isAvailable(capsSection?.search)),
+ tvSearch: Boolean(isAvailable(capsSection?.["tv-search"])),
+ movieSearch: Boolean(isAvailable(capsSection?.["movie-search"])),
+ };
+}
- const outcomes = await Promise.allSettled(
- urls.map((url) => this.fetchCaps(url))
- );
+function createTorznabSearchQuery(name: string, caps: Caps) {
+ const nameWithoutExtension = stripExtension(name);
+ const extractNumber = (str: string): number =>
+ parseInt(str.match(/\d+/)[0]);
+ const mediaType = getTag(nameWithoutExtension);
+ if (mediaType === MediaType.EPISODE && caps.tvSearch) {
+ const match = nameWithoutExtension.match(EP_REGEX);
+ return {
+ t: "tvsearch",
+ q: cleanseSeparators(match.groups.title),
+ season: extractNumber(match.groups.season),
+ ep: extractNumber(match.groups.episode),
+ } as const;
+ } else if (mediaType === MediaType.SEASON && caps.tvSearch) {
+ const match = nameWithoutExtension.match(SEASON_REGEX);
+ return {
+ t: "tvsearch",
+ q: cleanseSeparators(match.groups.title),
+ season: extractNumber(match.groups.season),
+ } as const;
+ } else {
+ return {
+ t: "search",
+ q: reformatTitleForSearching(nameWithoutExtension),
+ } as const;
+ }
+}
- const zipped: [URL, PromiseSettledResult][] = zip(urls, outcomes);
+export async function queryRssFeeds(): Promise {
+ const candidatesByUrl = await makeRequests(
+ "",
+ await getEnabledIndexers(),
+ () => ({ t: "search", q: "" })
+ );
+ return candidatesByUrl.flatMap((e) => e.candidates);
+}
- // handle promise rejections
- const rejected: [URL, PromiseRejectedResult][] = zipped.filter(
- (bundle): bundle is [URL, PromiseRejectedResult] =>
- bundle[1].status === "rejected"
+export async function searchTorznab(
+ name: string
+): Promise<{ indexerId: number; candidates: Candidate[] }[]> {
+ const { excludeRecentSearch, excludeOlder } = getRuntimeConfig();
+
+ const enabledIndexers = await getEnabledIndexers();
+
+ // search history for name across all indexers
+ const timestampDataSql = await db("searchee")
+ .join("timestamp", "searchee.id", "timestamp.searchee_id")
+ .join("indexer", "timestamp.indexer_id", "indexer.id")
+ .whereIn(
+ "indexer.id",
+ enabledIndexers.map((i) => i.id)
+ )
+ .andWhere({ name })
+ .select({
+ indexerId: "indexer.id",
+ firstSearched: "timestamp.first_searched",
+ lastSearched: "timestamp.last_searched",
+ });
+ const indexersToUse = enabledIndexers.filter((indexer) => {
+ const entry = timestampDataSql.find(
+ (entry) => entry.indexerId === indexer.id
+ );
+ return (
+ !entry ||
+ ((!excludeOlder || entry.firstSearched > nMsAgo(excludeOlder)) &&
+ (!excludeRecentSearch ||
+ entry.lastSearched < nMsAgo(excludeRecentSearch)))
);
+ });
- for (const [url, outcome] of rejected) {
- logger.warn(`Failed to reach ${url}`);
- logger.debug(outcome.reason);
- }
+ const timestampCallout = " (filtered by timestamps)";
+ logger.info({
+ label: Label.TORZNAB,
+ message: `Searching ${indexersToUse.length} indexers for ${name}${
+ indexersToUse.length < enabledIndexers.length
+ ? timestampCallout
+ : ""
+ }`,
+ });
+
+ return makeRequests(name, indexersToUse, (indexer) =>
+ createTorznabSearchQuery(name, {
+ search: indexer.searchCap,
+ tvSearch: indexer.tvSearchCap,
+ movieSearch: indexer.movieSearchCap,
+ })
+ );
+}
+
+export async function syncWithDb() {
+ const { torznab } = getRuntimeConfig();
+
+ const dbIndexers = await db("indexer")
+ .where({ active: true })
+ .select({
+ id: "id",
+ url: "url",
+ apikey: "apikey",
+ active: "active",
+ status: "status",
+ retryAfter: "retry_after",
+ searchCap: "search_cap",
+ tvSearchCap: "tv_search_cap",
+ movieSearchCap: "movie_search_cap",
+ });
+
+ const inConfigButNotInDb = torznab.filter(
+ (configIndexer) =>
+ !dbIndexers.some(
+ (dbIndexer) => dbIndexer.url === sanitizeUrl(configIndexer)
+ )
+ );
- const fulfilled = zipped
- .filter(
- (bundle): bundle is [URL, PromiseFulfilledResult] =>
- bundle[1].status === "fulfilled"
+ const inDbButNotInConfig = dbIndexers.filter(
+ (dbIndexer) =>
+ !torznab.some(
+ (configIndexer) => sanitizeUrl(configIndexer) === dbIndexer.url
)
- .map(
- ([url, outcome]: [URL, PromiseFulfilledResult]): [
- URL,
- Caps
- ] => [url, outcome.value]
+ );
+
+ const apikeyUpdates = dbIndexers.reduce<{ id: number; apikey: string }[]>(
+ (acc, dbIndexer) => {
+ const configIndexer = torznab.find(
+ (configIndexer) => sanitizeUrl(configIndexer) === dbIndexer.url
);
+ if (
+ configIndexer &&
+ dbIndexer.apikey !== getApikey(configIndexer)
+ ) {
+ acc.push({
+ id: dbIndexer.id,
+ apikey: getApikey(configIndexer),
+ });
+ }
+ return acc;
+ },
+ []
+ );
- // handle trackers that can't search
- const trackersWithoutSearchingCaps = fulfilled.filter(
- ([, caps]) => !caps.search
- );
- trackersWithoutSearchingCaps
- .map(
- ([url]) =>
- `Ignoring indexer that doesn't support searching: ${url}`
+ if (inDbButNotInConfig.length > 0) {
+ await db("indexer")
+ .whereIn(
+ "url",
+ inDbButNotInConfig.map((indexer) => indexer.url)
)
- .forEach(logger.warn);
+ .update({ active: false });
+ }
- // store caps of usable trackers
- const trackersWithSearchingCaps = fulfilled.filter(
- ([, caps]) => caps.search
- );
+ if (inConfigButNotInDb.length > 0) {
+ await db("indexer")
+ .insert(
+ inConfigButNotInDb.map((url) => ({
+ url: sanitizeUrl(url),
+ apikey: getApikey(url),
+ active: true,
+ }))
+ )
+ .onConflict("url")
+ .merge(["active", "apikey"]);
+ }
- for (const [url, caps] of trackersWithSearchingCaps) {
- this.capsMap.set(url, caps);
+ await db.transaction(async (trx) => {
+ for (const apikeyUpdate of apikeyUpdates) {
+ await trx("indexer")
+ .where({ id: apikeyUpdate.id })
+ .update({ apikey: apikeyUpdate.apikey });
}
+ });
+}
- if (trackersWithSearchingCaps.length === 0) {
- throw new CrossSeedError("no working indexers available");
- }
- }
+function assembleUrl(
+ urlStr: string,
+ apikey: string,
+ params: TorznabParams
+): string {
+ const url = new URL(urlStr);
+ const searchParams = new URLSearchParams();
- async fetchCaps(url: string | URL): Promise {
- return fetch(this.assembleUrl(url, { t: "caps" }))
- .then((r) => r.text())
- .then(xml2js.parseStringPromise)
- .then(this.parseCaps);
- }
+ searchParams.set("apikey", apikey);
- parseCaps(xml): Caps {
- const capsSection = xml?.caps?.searching?.[0];
- const isAvailable = (searchTechnique) =>
- searchTechnique?.[0]?.$?.available === "yes";
- return {
- search: isAvailable(capsSection?.search),
- tvSearch: isAvailable(capsSection?.["tv-search"]),
- movieSearch: isAvailable(capsSection?.["movie-search"]),
- };
+ for (const [key, value] of Object.entries(params)) {
+ if (value != null) searchParams.set(key, value);
}
- async parseResults(text: string): Promise {
- const jsified = await xml2js.parseStringPromise(text);
- const items = jsified?.rss?.channel?.[0]?.item;
- if (!items || !Array.isArray(items)) {
- return [];
- }
+ url.search = searchParams.toString();
+ return url.toString();
+}
+
+function fetchCaps(indexer: {
+ id: number;
+ url: string;
+ apikey: string;
+}): Promise {
+ return fetch(assembleUrl(indexer.url, indexer.apikey, { t: "caps" }))
+ .then((r) => r.text())
+ .then(xml2js.parseStringPromise)
+ .then(parseTorznabCaps);
+}
+
+function collateOutcomes(
+ correlators: Correlator[],
+ outcomes: PromiseSettledResult[]
+): {
+ rejected: [Correlator, PromiseRejectedResult["reason"]][];
+ fulfilled: [Correlator, SuccessReturnType][];
+} {
+ return outcomes.reduce<{
+ rejected: [Correlator, PromiseRejectedResult["reason"]][];
+ fulfilled: [Correlator, SuccessReturnType][];
+ }>(
+ ({ rejected, fulfilled }, cur, idx) => {
+ if (cur.status === "rejected") {
+ rejected.push([correlators[idx], cur.reason]);
+ } else {
+ fulfilled.push([correlators[idx], cur.value]);
+ }
+ return { rejected, fulfilled };
+ },
+ { rejected: [], fulfilled: [] }
+ );
+}
- return items.map((item) => ({
- guid: item.guid[0],
- name: item.title[0],
- tracker:
- item?.prowlarrindexer?.[0]?._ ??
- item?.jackettindexer?.[0]?._ ??
- item?.indexer?.[0]?._ ??
- "Unknown tracker",
- link: item.link[0],
- size: Number(item.size[0]),
- }));
+async function updateCaps(
+ indexers: { id: number; url: string; apikey: string }[]
+): Promise {
+ const outcomes = await Promise.allSettled(
+ indexers.map((indexer) => fetchCaps(indexer))
+ );
+ const { fulfilled, rejected } = collateOutcomes(
+ indexers.map((i) => i.id),
+ outcomes
+ );
+ for (const [indexerId, reason] of rejected) {
+ logger.warn(
+ `Failed to reach ${indexers.find((i) => i.id === indexerId).url}`
+ );
+ logger.debug(reason);
}
- getBestSearchTechnique(name: string, caps: Caps): TorznabParams {
- const extractNumber = (str: string): number =>
- parseInt(str.match(/\d+/)[0]);
- const mediaType = getTag(name);
- if (mediaType === MediaType.EPISODE && caps.tvSearch) {
- const match = name.match(EP_REGEX);
- return {
- t: "tvsearch",
- q: cleanseSeparators(match.groups.title),
- season: extractNumber(match.groups.season),
- ep: extractNumber(match.groups.episode),
- };
- } else if (mediaType === MediaType.SEASON && caps.tvSearch) {
- const match = name.match(SEASON_REGEX);
- return {
- t: "tvsearch",
- q: cleanseSeparators(match.groups.title),
- season: extractNumber(match.groups.season),
- };
- } else {
- return {
- t: "search",
- q: reformatTitleForSearching(name),
- };
+ for (const [indexerId, caps] of fulfilled) {
+ await db("indexer").where({ id: indexerId }).update({
+ search_cap: caps.search,
+ tv_search_cap: caps.tvSearch,
+ movie_search_cap: caps.movieSearch,
+ });
+ }
+}
+
+export async function validateTorznabUrls() {
+ const { torznab } = getRuntimeConfig();
+ if (!torznab) return;
+
+ const urls: URL[] = torznab.map((str) => new URL(str));
+ for (const url of urls) {
+ if (!url.pathname.endsWith("/api")) {
+ throw new CrossSeedError(
+ `Torznab url ${url} must have a path ending in /api`
+ );
+ }
+ if (!url.searchParams.has("apikey")) {
+ throw new CrossSeedError(
+ `Torznab url ${url} does not specify an apikey`
+ );
}
}
+ await syncWithDb();
+ const enabledIndexersWithoutCaps = await db("indexer")
+ .where({
+ active: true,
+ search_cap: null,
+ tv_search_cap: null,
+ movie_search_cap: null,
+ })
+ .select({ id: "id", url: "url", apikey: "apikey" });
+ await updateCaps(enabledIndexersWithoutCaps);
- async searchTorznab(
- name: string,
- nonceOptions = EmptyNonceOptions
- ): Promise {
- const searchUrls = Array.from(this.capsMap).map(
- ([url, caps]: [URL, Caps]) => {
- return this.assembleUrl(
- url,
- this.getBestSearchTechnique(name, caps)
- );
- }
- );
- searchUrls.forEach(
- (message) => void logger.verbose({ label: Label.TORZNAB, message })
- );
- const outcomes = await Promise.allSettled(
- searchUrls.map((url) =>
- fetch(url)
- .then((r) => r.text())
- .then(this.parseResults)
- )
- );
- const rejected = zip(Array.from(this.capsMap.keys()), outcomes).filter(
- ([, outcome]) => outcome.status === "rejected"
+ const indexersWithoutSearch = await db("indexer")
+ .where({ search_cap: false, active: true })
+ .select({ id: "id", url: "url" });
+
+ for (const indexer of indexersWithoutSearch) {
+ logger.warn(
+ `Ignoring indexer that doesn't support searching: ${indexer.url}`
);
- rejected
- .map(
- ([url, outcome]) =>
- `Failed searching ${url} for ${name} with reason: ${outcome.reason}`
- )
- .forEach(logger.warn);
+ }
- const fulfilled = outcomes
- .filter(
- (outcome): outcome is PromiseFulfilledResult =>
- outcome.status === "fulfilled"
- )
- .map((outcome) => outcome.value);
- return [].concat(...fulfilled);
+ const indexersWithSearch = await getEnabledIndexers();
+
+ if (indexersWithSearch.length === 0) {
+ throw new CrossSeedError("no working indexers available");
}
}
-function instantiateTorznabManager() {
- activeTorznabManager = new TorznabManager();
-}
+async function makeRequests(
+ name: string,
+ indexers: Indexer[],
+ getQuery: (indexer: Indexer) => TorznabParams
+): Promise<{ indexerId: number; candidates: Candidate[] }[]> {
+ const searchUrls = indexers.map((indexer: Indexer) =>
+ assembleUrl(indexer.url, indexer.apikey, getQuery(indexer))
+ );
+ searchUrls.forEach(
+ (message) => void logger.verbose({ label: Label.TORZNAB, message })
+ );
+ const abortControllers = Array.from(
+ new Array(20),
+ () => new AbortController()
+ );
+
+ setTimeout(() => {
+ for (const abortController of abortControllers) {
+ abortController.abort();
+ }
+ }, 30000).unref();
-export function getTorznabManager(): TorznabManager {
- if (!activeTorznabManager) {
- instantiateTorznabManager();
+ const outcomes = await Promise.allSettled(
+ searchUrls.map((url, i) =>
+ fetch(url, {
+ headers: { "User-Agent": "cross-seed" },
+ signal: abortControllers[i].signal,
+ })
+ .then((response) => {
+ if (!response.ok) {
+ if (response.status === 429) {
+ updateIndexerStatus(
+ IndexerStatus.RATE_LIMITED,
+ Date.now() + ms("1 hour"),
+ [indexers[i].id]
+ );
+ } else {
+ updateIndexerStatus(
+ IndexerStatus.UNKNOWN_ERROR,
+ Date.now() + ms("1 hour"),
+ [indexers[i].id]
+ );
+ }
+ throw new Error(
+ `request failed with code: ${response.status}`
+ );
+ }
+ return response;
+ })
+ .then((r) => r.text())
+ .then(xml2js.parseStringPromise)
+ .then(parseTorznabResults)
+ )
+ );
+
+ const { rejected, fulfilled } = collateOutcomes(
+ indexers.map((indexer) => indexer.id),
+ outcomes
+ );
+
+ for (const [indexerId, reason] of rejected) {
+ logger.warn(
+ `Failed to reach ${indexers.find((i) => i.id === indexerId).url}`
+ );
+ logger.debug(reason);
}
- return activeTorznabManager;
+
+ return fulfilled.map(([indexerId, results]) => ({
+ indexerId,
+ candidates: results,
+ }));
}
diff --git a/src/utils.ts b/src/utils.ts
index 751f8e18c..89444ac81 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -12,8 +12,6 @@ export enum MediaType {
OTHER = "unknown",
}
-export const duration = (perfA, perfB) => (perfB - perfA).toFixed(0);
-
export function stripExtension(filename: string): string {
for (const ext of EXTENSIONS) {
const re = new RegExp(`\\.${ext}$`);
@@ -40,10 +38,6 @@ export function getTag(name: string): MediaType {
: MediaType.OTHER;
}
-export type Result = T | Error;
-
-export const ok = (r: Result): r is T => !(r instanceof Error);
-
export async function time(cb: () => R, times: number[]) {
const before = performance.now();
try {
@@ -68,8 +62,9 @@ export function reformatTitleForSearching(name: string): string {
episodeMatch?.[0] ?? seasonMatch?.[0] ?? movieMatch?.[0] ?? name;
return cleanseSeparators(fullMatch);
}
-export const tapLog = (value) => {
- console.log(value);
+
+export const tap = (fn) => (value) => {
+ fn(value);
return value;
};
@@ -78,3 +73,16 @@ export async function filterAsync(arr, predicate) {
return arr.filter((_, index) => results[index]);
}
+
+export function humanReadable(timestamp: number): string {
+ // swedish conventions roughly follow the iso format!
+ return new Date(timestamp).toLocaleString("sv");
+}
+
+export function formatAsList(strings: string[]) {
+ // @ts-expect-error Intl.ListFormat totally exists on node 12
+ return new Intl.ListFormat("en", {
+ style: "long",
+ type: "conjunction",
+ }).format(strings);
+}