diff --git a/package-lock.json b/package-lock.json index 9e2d33ef..0dab7ad6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -549,6 +549,135 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@node-datachannel/android-arm64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@node-datachannel/android-arm64/-/android-arm64-0.33.0.tgz", + "integrity": "sha512-3/Q8koe++/4X8jh5HvNH4/VTh+4XPHVAuBNZVsE9eI50tyOb9j21rxSfLJTZRK1KL16ELtD7okCMhLx7B/Lpgg==", + "cpu": [ + "arm64" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@node-datachannel/darwin-arm64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/darwin-arm64/-/darwin-arm64-0.33.1.tgz", + "integrity": "sha512-6reyGKzuYNzuJypm4KrpJVTpION39rZmLoqDNMiehTVuSZzV1yoYyLHCzJ9XNVpOViGdaUvAWXJTlHcoQOZtrw==", + "cpu": [ + "arm64" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@node-datachannel/darwin-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/darwin-x64/-/darwin-x64-0.33.1.tgz", + "integrity": "sha512-1zXH/E79bswwRfbUwilw9iPNCCI4GLul/xxsjx/H7jbPT9SeMgkHKcx7Emuw91NBeYYPvYSYwQ705h4FTVDxow==", + "cpu": [ + "x64" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@node-datachannel/linux-arm64-gnu": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/linux-arm64-gnu/-/linux-arm64-gnu-0.33.1.tgz", + "integrity": "sha512-FriA+y9cKnr9shQaNz4AdqkaNb7yqBcj1U/OgAlLvJtY/mJLvRX7R3iic18aUA5BkMMK+wwqBI/0Al3brxdRAw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@node-datachannel/linux-arm64-musl": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/linux-arm64-musl/-/linux-arm64-musl-0.33.1.tgz", + "integrity": "sha512-MGtNFlIZ5b9sRBDFD3xS3oAhmAxJyEDrrhuUUEtR4Z4boSGOIzg0IiE5YQalmG7XgpMe0cxigBBTJitDczYyNQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@node-datachannel/linux-x64-gnu": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/linux-x64-gnu/-/linux-x64-gnu-0.33.1.tgz", + "integrity": "sha512-0mTxq+0fYatoQ/7y9uMLDSRbnb0/Vrrl1Fhsuys8PfB06ft3IA8+6/qdFgRriHbCNkCkB3mSvWEIjCVjXuPr1A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@node-datachannel/linux-x64-musl": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/linux-x64-musl/-/linux-x64-musl-0.33.1.tgz", + "integrity": "sha512-LSIdM/tpdwy8d+pN7Vj5Dd4kU3vtdh1PKzz8MYPEPKpv2If5gBydA9pvmGDldiyn07Ijj1TrIQhV+1ozGBm0dw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@node-datachannel/win32-arm64-msvc": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/win32-arm64-msvc/-/win32-arm64-msvc-0.33.1.tgz", + "integrity": "sha512-uERR6Zz3wqLnOhBe1YRFodFSD9c7N3aM2Upe/z7miR7F8sprLr00+SkrTXZ+RayW8BN+xDtZRKFYYROvuJy/ug==", + "cpu": [ + "arm64" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@node-datachannel/win32-x64-msvc": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@node-datachannel/win32-x64-msvc/-/win32-x64-msvc-0.33.1.tgz", + "integrity": "sha512-i5c3t+hDz6oNcen8aQZetxtAfYnNeUEND67oJrnAL8qQFzaRv6wKO5Q7G1vpjzQUyC54xe6s4oD8uuVo2sKPzA==", + "cpu": [ + "x64" + ], + "license": "MPL 2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@oxc-project/types": { "version": "0.146.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", @@ -2096,26 +2225,6 @@ "node": ">= 0.6.0" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/bencode": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/bencode/-/bencode-4.0.1.tgz", @@ -2318,17 +2427,6 @@ "utf-8-validate": "^6.0.4" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/block-iterator": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/block-iterator/-/block-iterator-1.1.2.tgz", @@ -2345,30 +2443,6 @@ "concat-map": "0.0.1" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/bufferutil": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", @@ -2480,12 +2554,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/chrome-dgram": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/chrome-dgram/-/chrome-dgram-3.0.6.tgz", @@ -2790,30 +2858,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/default-gateway": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-7.2.2.tgz", @@ -3031,15 +3075,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3176,12 +3211,6 @@ "node": ">=12.20.0" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fs-native-extensions": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/fs-native-extensions/-/fs-native-extensions-1.5.0.tgz", @@ -3253,12 +3282,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/http-parser-js": { "version": "0.4.13", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.4.13.tgz", @@ -3274,26 +3297,6 @@ "node": ">=14.18.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/immediate-chunk-store": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/immediate-chunk-store/-/immediate-chunk-store-2.2.0.tgz", @@ -3335,12 +3338,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/ink": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", @@ -4056,18 +4053,6 @@ "node": ">=6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4089,12 +4074,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -4145,12 +4124,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/napi-macros": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz", @@ -4167,29 +4140,27 @@ "node": ">= 0.4.0" } }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-datachannel": { - "version": "0.32.3", - "resolved": "https://registry.npmjs.org/node-datachannel/-/node-datachannel-0.32.3.tgz", - "integrity": "sha512-Aok1ZhLsll472lRefgWYuWJ0070jh0ecHravTdRyZEmoESumebMEQV8Y+poBwSW2ZbEwAokAOGsK5Cu8pDDT2g==", - "hasInstallScript": true, + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/node-datachannel/-/node-datachannel-0.33.1.tgz", + "integrity": "sha512-9oryOvUx2WqOmMUQ+JHyCx9w9UPBLGQcZQNcSYDc1LZS/hFSAqDyIRt6b5H5Ue13Gpe/vTjU8y9UvCBrbwpbAQ==", "license": "MPL 2.0", "dependencies": { - "prebuild-install": "^7.1.3" + "detect-libc": "^2.0.4" }, "engines": { "node": ">=18.20.0" + }, + "optionalDependencies": { + "@node-datachannel/android-arm64": "0.33.1", + "@node-datachannel/darwin-arm64": "0.33.1", + "@node-datachannel/darwin-x64": "0.33.1", + "@node-datachannel/linux-arm64-gnu": "0.33.1", + "@node-datachannel/linux-arm64-musl": "0.33.1", + "@node-datachannel/linux-x64-gnu": "0.33.1", + "@node-datachannel/linux-x64-musl": "0.33.1", + "@node-datachannel/win32-arm64-msvc": "0.33.1", + "@node-datachannel/win32-x64-msvc": "0.33.1" } }, "node_modules/node-domexception": { @@ -4508,33 +4479,6 @@ } } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -4619,21 +4563,6 @@ "node": ">= 0.6" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, "node_modules/rc4": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/rc4/-/rc4-0.1.5.tgz", @@ -4672,6 +4601,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", + "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -4924,18 +4854,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/serve-handler": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", @@ -4985,51 +4903,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/slice-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", @@ -5156,6 +5029,7 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", + "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -5225,15 +5099,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -5269,34 +5134,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -5561,18 +5398,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-fest": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", @@ -5734,7 +5559,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/utp-native": { "version": "2.5.3", diff --git a/package.json b/package.json index fdd4a597..33ff81df 100644 --- a/package.json +++ b/package.json @@ -87,8 +87,10 @@ "utp-native@2.5.3": true }, "overrides": { + "@node-datachannel/android-arm64": "0.33.0", "esbuild": "^0.28.2", "ip": "^2.0.1", + "node-datachannel": "^0.33.1", "uint8-util": "2.2.6" } } diff --git a/scripts/cli-entry.cjs b/scripts/cli-entry.cjs index cfd55a2c..5914bbc4 100644 --- a/scripts/cli-entry.cjs +++ b/scripts/cli-entry.cjs @@ -12,47 +12,71 @@ if (major < 26) { process.exit(1); } -// The WebRTC stack (webtorrent -> simple-peer -> webrtc-polyfill) eagerly -// requires node-datachannel's native binary, which only install scripts -// download; npm 12 skips those scripts by default, so the binary is often -// absent and the eager import would kill startup. When it cannot load, -// resolve webrtc-polyfill to an inert stub instead: simple-peer then reports +// Resolve webrtc-polyfill to an inert stub: simple-peer then reports // WEBRTC_SUPPORT = false and downloads run on TCP/uTP and DHT peers alone. -try { - require('node-datachannel'); -} catch (err) { +// Returns false on Node 22.0 to 22.14, which has no module.registerHooks and +// so cannot redirect the eager import at all. +function useWebrtcStub() { var Module = require('node:module'); - if (typeof Module.registerHooks === 'function') { - var stubUrl = require('node:url') - .pathToFileURL(require('node:path').join(__dirname, 'webrtc-stub.mjs')) - .href; - Module.registerHooks({ - resolve: function (specifier, context, nextResolve) { - if (specifier === 'webrtc-polyfill') { - return { url: stubUrl, shortCircuit: true }; - } - return nextResolve(specifier, context); - }, - }); - process.stderr.write( - 'torlnk: WebRTC peers unavailable (native module not installed); ' + - 'TCP/UDP peers still work. https://github.com/baairon/torlink/issues/60\n' - ); + if (typeof Module.registerHooks !== 'function') return false; + var stubUrl = require('node:url') + .pathToFileURL(require('node:path').join(__dirname, 'webrtc-stub.mjs')) + .href; + Module.registerHooks({ + resolve: function (specifier, context, nextResolve) { + if (specifier === 'webrtc-polyfill') { + return { url: stubUrl, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + }); + return true; +} + +// The same switch, thrown on purpose. On some machines node-datachannel's +// native event loop burns a core for as long as torlink is open, idle or not +// (issue #119), and nothing inside it can be turned down at runtime. Rather +// than decide for a whole platform, let the person watching their fan spin opt +// out on their own machine: TCP/uTP and DHT peers are where torlink's swarms +// are anyway. Presence is the switch, like TORLINK_NO_UPDATE_CHECK. +if (process.env.TORLINK_NO_WEBRTC || process.env.KLINK_NO_WEBRTC) { + if (useWebrtcStub()) { + process.stderr.write('torlnk: WebRTC peers disabled by TORLINK_NO_WEBRTC.\n'); } else { - // Node 22.0 to 22.14 has no module.registerHooks, so the eager import - // cannot be redirected; a clear explanation beats the raw module error. process.stderr.write( - '\ntorlnk needs the WebRTC native module (node-datachannel), and it is\n' + - 'not installed. Either upgrade to Node 22.15+ (torlnk then runs\n' + - 'without WebRTC peers), or install the build tools and reinstall:\n' + - ' Fedora: sudo dnf install cmake gcc-c++ openssl-devel libstdc++-static\n' + - ' Debian / Ubuntu: sudo apt install cmake g++ libssl-dev\n' + - ' macOS: xcode-select --install\n' + - ' Windows: install CMake and Visual Studio Build Tools\n' + - 'On npm 12, also allow install scripts: npm approve-scripts\n\n' + - 'https://github.com/baairon/torlink/issues/60\n\n' + 'torlnk: TORLINK_NO_WEBRTC needs Node 22.15 or later to take effect; ' + + 'WebRTC peers stay enabled on v' + process.versions.node + '.\n' ); - process.exit(1); + } +} else { + // The WebRTC stack (webtorrent -> simple-peer -> webrtc-polyfill) eagerly + // requires node-datachannel's native binary, which only install scripts + // download; npm 12 skips those scripts by default, so the binary is often + // absent and the eager import would kill startup. + try { + require('node-datachannel'); + } catch (err) { + if (useWebrtcStub()) { + process.stderr.write( + 'torlnk: WebRTC peers unavailable (native module not installed); ' + + 'TCP/UDP peers still work. https://github.com/baairon/torlink/issues/60\n' + ); + } else { + // Node 22.0 to 22.14 has no module.registerHooks, so the eager import + // cannot be redirected; a clear explanation beats the raw module error. + process.stderr.write( + '\ntorlnk needs the WebRTC native module (node-datachannel), and it is\n' + + 'not installed. Either upgrade to Node 22.15+ (torlnk then runs\n' + + 'without WebRTC peers), or install the build tools and reinstall:\n' + + ' Fedora: sudo dnf install cmake gcc-c++ openssl-devel libstdc++-static\n' + + ' Debian / Ubuntu: sudo apt install cmake g++ libssl-dev\n' + + ' macOS: xcode-select --install\n' + + ' Windows: install CMake and Visual Studio Build Tools\n' + + 'On npm 12, also allow install scripts: npm approve-scripts\n\n' + + 'https://github.com/baairon/torlink/issues/60\n\n' + ); + process.exit(1); + } } } diff --git a/scripts/cli-entry.test.mjs b/scripts/cli-entry.test.mjs new file mode 100644 index 00000000..094c8345 --- /dev/null +++ b/scripts/cli-entry.test.mjs @@ -0,0 +1,30 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect } from "vitest"; + +const entry = fileURLToPath(new URL("./cli-entry.cjs", import.meta.url)); + +// cli-entry settles the WebRTC question and only then imports ./index.js, +// which exists in dist/ and not in scripts/. Run from here that import fails +// and the process exits, which leaves exactly the part under test on stderr. +function runEntry(env) { + const res = spawnSync(process.execPath, [entry], { + env: { ...process.env, ...env }, + encoding: "utf8", + }); + return res.stderr ?? ""; +} + +describe("TORLINK_NO_WEBRTC", () => { + it("turns WebRTC off when it is set", () => { + // The second branch is Node 22.0-22.14, where module.registerHooks does not + // exist and the opt-out says so rather than pretending to work. + expect(runEntry({ TORLINK_NO_WEBRTC: "1" })).toMatch( + /WebRTC peers disabled by TORLINK_NO_WEBRTC|TORLINK_NO_WEBRTC needs Node 22\.15/, + ); + }); + + it("says nothing about the flag when it is unset", () => { + expect(runEntry({ TORLINK_NO_WEBRTC: "" })).not.toMatch(/TORLINK_NO_WEBRTC/); + }); +}); diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index e802cb0e..2eba0bba 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -40,6 +40,44 @@ describe("parseCliArgs", () => { expect(parseCliArgs(["update"])).toEqual({ kind: "update", force: false }); expect(parseCliArgs(["update", "--force"])).toEqual({ kind: "update", force: true }); }); + it("parses headless searches", () => { + expect(parseCliArgs(["search", "ubuntu"])).toEqual({ kind: "search", query: "ubuntu" }); + expect(parseCliArgs(["search", "example", "movie", "--category", "movies"])).toEqual({ + kind: "search", + query: "example movie", + category: "movies", + }); + expect(parseCliArgs(["search", "--category", "games", "ubuntu"])).toEqual({ + kind: "search", + query: "ubuntu", + category: "games", + }); + expect(parseCliArgs(["search", "tolkien", "--category", "ebooks"])).toEqual({ + kind: "search", + query: "tolkien", + category: "ebooks", + }); + expect(parseCliArgs(["search", "sanderson", "--category", "audiobooks"])).toEqual({ + kind: "search", + query: "sanderson", + category: "audiobooks", + }); + }); + it("rejects invalid headless searches", () => { + expect(parseCliArgs(["search"])).toEqual({ kind: "invalid", arg: "search (missing query)" }); + expect(parseCliArgs(["search", "ubuntu", "--category", "invalidcat"])).toEqual({ + kind: "invalid", + arg: "search (invalid category 'invalidcat')", + }); + expect(parseCliArgs(["search", "ubuntu", "--limit", "10"])).toEqual({ + kind: "invalid", + arg: "search (unknown --limit)", + }); + expect(parseCliArgs(["search", "ubuntu", "--category"])).toEqual({ + kind: "invalid", + arg: "search (invalid --category)", + }); + }); it("parses watch with a directory", () => { expect(parseCliArgs(["watch", "/srv/blackhole"])).toEqual({ kind: "watch", diff --git a/src/cli/args.ts b/src/cli/args.ts index fbae28ed..819616cf 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,6 +1,8 @@ import { isInfoHash } from "../sources/magnet"; import { parseDuration } from "../util/duration"; +export type SearchCategory = "games" | "movies" | "tv" | "anime" | "ebooks" | "audiobooks"; + export type CliCommand = | { kind: "version" } | { kind: "help" } @@ -26,6 +28,7 @@ export type CliCommand = | { kind: "files"; port?: number; host?: string; token?: string; dir?: string; daemon?: boolean } | { kind: "attach" } | { kind: "update"; force?: boolean } + | { kind: "search"; query: string; category?: SearchCategory } | { kind: "invalid"; arg: string }; // Valueless boolean flags for the headless subcommands (everything else is a @@ -77,6 +80,30 @@ export function parseCliArgs(argv: string[]): CliCommand { if (a === "--help" || a === "-h") return { kind: "help" }; if (a === "attach") return { kind: "attach" }; if (a === "update") return { kind: "update", force: args.slice(1).includes("--force") }; + if (a === "search") { + const { flags, rest } = readFlags(args.slice(1)); + const unknownFlag = Object.keys(flags).find((flag) => flag !== "category"); + const danglingFlag = rest.find((arg) => arg.startsWith("--")); + if (unknownFlag) return { kind: "invalid", arg: `search (unknown --${unknownFlag})` }; + if (danglingFlag) return { kind: "invalid", arg: `search (invalid ${danglingFlag})` }; + + const query = rest.join(" ").trim(); + if (!query) return { kind: "invalid", arg: "search (missing query)" }; + + const category = flags.category; + if (category === undefined) return { kind: "search", query }; + if ( + category === "games" || + category === "movies" || + category === "tv" || + category === "anime" || + category === "ebooks" || + category === "audiobooks" + ) { + return { kind: "search", query, category }; + } + return { kind: "invalid", arg: `search (invalid category '${category}')` }; + } if (a === "watch") { const { bools, rest: r0 } = splitBooleans(args.slice(1)); const { flags, rest } = readFlags(r0); @@ -129,6 +156,8 @@ usage torlnk open the search TUI torlnk "magnet:?xt=..." start a download on launch torlnk path/to/file.torrent open a .torrent file on launch + torlnk search headless: print search results as JSON + [--category games|movies|tv|anime|ebooks|audiobooks] torlnk watch headless: download torrents dropped into torlnk serve headless: HTTP add API (POST /add) on :9161 torlnk files headless: serve downloads over HTTP on :9160 diff --git a/src/cli/search.test.ts b/src/cli/search.test.ts new file mode 100644 index 00000000..cbe6abe9 --- /dev/null +++ b/src/cli/search.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { cachedSearch } from "../sources/cache"; +import { sourcesByGroup } from "../sources/registry"; +import type { TorrentResult } from "../sources/types"; +import { HttpError } from "../util/net"; +import { runSearch } from "./search"; + +vi.mock("../sources/cache", () => ({ cachedSearch: vi.fn() })); + +const searchMock = vi.mocked(cachedSearch); + +function result( + infoHash: string, + seeders: number, + added: number, + source: TorrentResult["source"], +): TorrentResult { + return { + infoHash, + name: infoHash, + source, + sizeBytes: 1, + seeders, + leechers: 0, + added, + magnet: `magnet:?xt=urn:btih:${infoHash}`, + }; +} + +beforeEach(() => { + searchMock.mockReset(); +}); + +describe("runSearch", () => { + it("selects category sources and preserves partial failures", async () => { + searchMock.mockImplementation(async (source) => { + if (source.id === "yts") return [result("same", 10, 100, source.id)]; + if (source.id === "tpb-movies") { + return [result("same", 20, 100, source.id), result("other", 15, 200, source.id)]; + } + throw new HttpError(503, "source unavailable"); + }); + + const execution = await runSearch({ query: "example movie", category: "movies" }); + const movieSourceIds = sourcesByGroup() + .find(({ group }) => group === "Movies")! + .sources.map(({ id }) => id); + + expect(searchMock.mock.calls.map(([source]) => source.id)).toEqual(movieSourceIds); + expect(execution.exitCode).toBe(0); + expect(execution.document.sources.yts).toEqual({ ok: true, count: 1, error: null, code: null }); + expect(execution.document.sources["x1337-movies"]).toEqual({ + ok: false, + count: 0, + error: "source unavailable", + code: "HTTP 503", + }); + expect( + execution.document.results.map(({ infoHash, seeders }) => ({ infoHash, seeders })), + ).toEqual([ + { infoHash: "same", seeders: 20 }, + { infoHash: "other", seeders: 15 }, + ]); + }); + + it("selects ebooks category sources correctly", async () => { + searchMock.mockResolvedValue([]); + const execution = await runSearch({ query: "tolkien", category: "ebooks" }); + const ebookSourceIds = sourcesByGroup() + .find(({ group }) => group === "E-Books")! + .sources.map(({ id }) => id); + + expect(searchMock.mock.calls.map(([source]) => source.id)).toEqual(ebookSourceIds); + expect(execution.exitCode).toBe(0); + expect(execution.document.category).toBe("ebooks"); + }); + + it("exits successfully when every source returns an empty result", async () => { + searchMock.mockResolvedValue([]); + + const execution = await runSearch({ query: "legitimate empty search" }); + + expect(execution.exitCode).toBe(0); + expect(execution.document.category).toBe("all"); + expect(execution.document.count).toBe(0); + }); + + it("returns diagnostic output and exit 1 when every source fails", async () => { + searchMock.mockRejectedValue(new Error("offline")); + + const execution = await runSearch({ query: "ubuntu", category: "games" }); + + expect(execution.exitCode).toBe(1); + expect(execution.document.results).toEqual([]); + expect(execution.document.sources.fitgirl).toEqual({ + ok: false, + count: 0, + error: "offline", + code: "no response", + }); + }); +}); diff --git a/src/cli/search.ts b/src/cli/search.ts new file mode 100644 index 00000000..235a3c75 --- /dev/null +++ b/src/cli/search.ts @@ -0,0 +1,103 @@ +import type { SearchCategory } from "./args"; +import { cachedSearch } from "../sources/cache"; +import { SOURCES, sourcesByGroup } from "../sources/registry"; +import { dedupeResults } from "../ui/dedupe"; +import { defaultOrder } from "../ui/sort"; +import type { Source, SourceGroup, SourceId, TorrentResult } from "../sources/types"; +import { HttpError } from "../util/net"; + +type OutputCategory = SearchCategory | "all"; + +interface SourceOutcome { + ok: boolean; + count: number; + error: string | null; + code: string | null; +} + +export interface SearchDocument { + query: string; + category: OutputCategory; + count: number; + sources: Partial>; + results: TorrentResult[]; +} + +export interface SearchExecution { + document: SearchDocument; + exitCode: 0 | 1; +} + +const GROUPS: Record = { + games: "Games", + movies: "Movies", + tv: "TV", + anime: "Anime", + ebooks: "E-Books", + audiobooks: "Audiobooks", +}; + +function selectSources(category: OutputCategory): readonly Source[] { + if (category === "all") return SOURCES; + return sourcesByGroup().find(({ group }) => group === GROUPS[category])?.sources ?? []; +} + +function errorCode(error: unknown): string { + if (error instanceof HttpError && error.status > 0) return `HTTP ${error.status}`; + return "no response"; +} + +export async function runSearch(options: { + query: string; + category?: SearchCategory; + signal?: AbortSignal; +}): Promise { + const category = options.category ?? "all"; + const attempts = await Promise.all( + selectSources(category).map(async (source) => { + try { + const results = await cachedSearch(source, options.query, { signal: options.signal }); + return { + source, + results, + outcome: { + ok: true, + count: results.length, + error: null, + code: null, + } satisfies SourceOutcome, + }; + } catch (error) { + return { + source, + results: [] as TorrentResult[], + outcome: { + ok: false, + count: 0, + error: error instanceof Error ? error.message : String(error), + code: errorCode(error), + } satisfies SourceOutcome, + }; + } + }), + ); + + const sources: Partial> = {}; + const collected: TorrentResult[] = []; + for (const attempt of attempts) { + sources[attempt.source.id] = attempt.outcome; + collected.push(...attempt.results); + } + + const results = defaultOrder(dedupeResults(collected)); + return { + document: { + query: options.query, + category, + count: results.length, + sources, + results, + }, + exitCode: attempts.some(({ outcome }) => outcome.ok) ? 0 : 1, + }; +} diff --git a/src/deps-pin.test.ts b/src/deps-pin.test.ts index 0588147e..3c130566 100644 --- a/src/deps-pin.test.ts +++ b/src/deps-pin.test.ts @@ -36,3 +36,88 @@ describe("uint8-util quarantine pin", () => { expect(() => asAny("fd568f2ceba6b2603e761e4b13e5308c8b0f8ae4")).not.toThrow(); }); }); + +// Aug-2026 install breakage: node-datachannel 0.33.0 removed its `install` +// script entirely (0.32.3 ran `prebuild-install -r napi || (npm install +// --ignore-scripts --production=false && npm run _prebuild)`) and moved the +// native binary into nine per-platform optionalDependencies. npm 12 blocks +// install scripts that are not explicitly approved, so on 0.32.3 a fresh +// install leaves node-datachannel without its binary and every import of it +// throws "Cannot find module '../../../build/Release/node_datachannel.node'" — +// the failure behind #166, #60, #81, #89, #135 and #20. Optional dependencies +// need no script at all. webrtc-polyfill still asks for "^0.32.3", and a caret +// on a 0.x version pins the minor, so only an override moves the tree onto the +// prebuilt line. The 0.33 loader still checks a local build/ directory before +// the platform package, which keeps scripts/ensure-webrtc.cjs working as the +// compile-from-source fallback where no prebuilt exists. +const PREBUILT_LINE = "^0.33.1"; +const ANDROID_PUBLISHED = "0.33.0"; + +function atLeast(version: string, min: string): boolean { + const a = version.split(".").map(Number); + const b = min.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0); + } + return true; +} + +describe("node-datachannel prebuilt binaries", () => { + it("package.json overrides the transitive range onto the prebuilt line", () => { + const pkg = readJson("../package.json"); + expect(pkg.overrides["node-datachannel"]).toBe(PREBUILT_LINE); + }); + + it("the lockfile resolved a version that ships prebuilt binaries", () => { + const lock = readJson("../package-lock.json"); + const resolved = lock.packages["node_modules/node-datachannel"].version; + expect(atLeast(resolved, "0.33.1")).toBe(true); + }); + + it("the installed copy carries no install script and declares platform packages", () => { + const installed = readJson("../node_modules/node-datachannel/package.json"); + expect(installed.scripts?.install).toBeUndefined(); + expect(Object.keys(installed.optionalDependencies ?? {})).toEqual( + expect.arrayContaining([ + "@node-datachannel/linux-x64-gnu", + "@node-datachannel/darwin-arm64", + "@node-datachannel/win32-x64-msvc", + ]), + ); + }); + + it("the native binding loads without any build step", async () => { + const ndc = await import("node-datachannel"); + expect(typeof ndc.PeerConnection).toBe("function"); + }); + + // 0.33.1 lists @node-datachannel/android-arm64@0.33.1 among its + // optionalDependencies and that version was never published -- 0.33.0 is the + // only one on the registry. npm then leaves the package out of the lockfile, + // and `npm ci` refuses the result outright: "Missing: + // @node-datachannel/android-arm64@ from lock file". So the override names the + // version that does exist. 0.33.1 only added a Windows ARM64 build, so the + // Android binary is the same one either way. Drop this once upstream + // publishes the missing package. + it("pins the platform package upstream declares but never published", () => { + const pkg = readJson("../package.json"); + expect(pkg.overrides["@node-datachannel/android-arm64"]).toBe(ANDROID_PUBLISHED); + const lock = readJson("../package-lock.json"); + expect(lock.packages["node_modules/@node-datachannel/android-arm64"].version).toBe( + ANDROID_PUBLISHED, + ); + }); + + it("the lockfile carries every platform package the module declares", () => { + const declared = Object.keys( + readJson("../node_modules/node-datachannel/package.json").optionalDependencies ?? {}, + ); + const lock = readJson("../package-lock.json"); + expect(declared.length).toBeGreaterThan(0); + for (const name of declared) { + expect(lock.packages[`node_modules/${name}`], `${name} missing from the lockfile`) + .toBeDefined(); + } + }); +}); + diff --git a/src/index.tsx b/src/index.tsx index f84a9d51..2eeacbc5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -29,7 +29,12 @@ if (cmd.kind === "invalid") { // try/catch or error event can reach (see util/crashlog.ts). Contained and // logged for every mode; headless runs also echo one line to their log. containUnhandledRejections({ - echo: cmd.kind === "update" || cmd.kind === "watch" || cmd.kind === "serve" || cmd.kind === "files", + echo: + cmd.kind === "update" || + cmd.kind === "search" || + cmd.kind === "watch" || + cmd.kind === "serve" || + cmd.kind === "files", }); // Run/reattach the TUI inside a persistent tmux session (execs tmux, then exits). @@ -74,6 +79,17 @@ if (cmd.kind === "update") { dir: cmd.dir, }; void import("./daemon/files").then(({ runFiles }) => runFiles(options).catch(failHeadless)); +} else if (cmd.kind === "search") { + // One JSON document on stdout, then exit: the shape a script can pipe into + // jq. Exit 1 only when every source failed, so an empty-but-healthy search + // is still a success. + void import("./cli/search") + .then(({ runSearch }) => runSearch({ query: cmd.query, category: cmd.category })) + .then(({ document, exitCode }) => { + process.exitCode = exitCode; + process.stdout.write(`${JSON.stringify(document)}\n`); + }) + .catch(failHeadless); } else { // Enter the alt-screen and hide the hardware cursor: the TUI draws its own diff --git a/src/sources/cache.test.ts b/src/sources/cache.test.ts new file mode 100644 index 00000000..a0d0a5a5 --- /dev/null +++ b/src/sources/cache.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import type { Source, SourceId, TorrentResult } from "./types"; + +const MAX_ENTRIES = 100; + +function countingSource(id: SourceId = "nyaa"): Source & { calls: number } { + const source = { + id, + label: id, + groups: ["Anime" as const], + homepage: "https://example.org", + reportsHealth: true, + calls: 0, + search: async (query: string): Promise => { + source.calls += 1; + return [ + { + infoHash: "a".repeat(40), + name: query, + sizeBytes: 1, + seeders: 1, + leechers: 0, + source: id, + magnet: `magnet:?xt=urn:btih:${"a".repeat(40)}`, + }, + ]; + }, + }; + return source; +} + +// The cache is module state, so each test starts from a fresh copy. +async function freshCache(): Promise { + vi.resetModules(); + return (await import("./cache")).cachedSearch; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("cachedSearch", () => { + it("serves a repeat query from the cache", async () => { + const cachedSearch = await freshCache(); + const source = countingSource(); + + await cachedSearch(source, "dune"); + const again = await cachedSearch(source, " DUNE "); + + expect(source.calls).toBe(1); + expect(again[0]!.name).toBe("dune"); + }); + + it("goes back to the source once the entry has aged out", async () => { + vi.useFakeTimers(); + const cachedSearch = await freshCache(); + const source = countingSource(); + + await cachedSearch(source, "dune"); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + await cachedSearch(source, "dune"); + + expect(source.calls).toBe(2); + }); + + it("keeps only the most recent entries instead of growing forever", async () => { + const cachedSearch = await freshCache(); + const source = countingSource(); + + for (let i = 0; i < MAX_ENTRIES + 10; i++) await cachedSearch(source, `query ${i}`); + const filled = source.calls; + + // The newest is still there; the oldest was dropped to make room. + await cachedSearch(source, `query ${MAX_ENTRIES + 9}`); + expect(source.calls).toBe(filled); + + await cachedSearch(source, "query 0"); + expect(source.calls).toBe(filled + 1); + }); + + it("counts a refreshed entry as the newest, not the oldest", async () => { + vi.useFakeTimers(); + const cachedSearch = await freshCache(); + const source = countingSource(); + + for (let i = 0; i < MAX_ENTRIES; i++) await cachedSearch(source, `query ${i}`); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + + // Refetching the oldest query moves it to the back of the queue, so the + // entry evicted by the next insert is the one after it, not this one. + await cachedSearch(source, "query 0"); + await cachedSearch(source, "brand new"); + const filled = source.calls; + + await cachedSearch(source, "query 0"); + expect(source.calls).toBe(filled); + + await cachedSearch(source, "query 1"); + expect(source.calls).toBe(filled + 1); + }); +}); diff --git a/src/sources/cache.ts b/src/sources/cache.ts index ace68055..8cf97eb5 100644 --- a/src/sources/cache.ts +++ b/src/sources/cache.ts @@ -2,6 +2,12 @@ import type { SearchOptions, Source, TorrentResult } from "./types"; const TTL_MS = 5 * 60 * 1000; +// A search fills one entry per source, so this is the last ten searches -- +// more history than a five-minute TTL can put to use. Nothing evicted anything +// before, so the map only ever grew, holding result lists the UI had long since +// replaced: 200 distinct searches across the ten sources retained about 80 MB. +const MAX_ENTRIES = 100; + interface Entry { at: number; results: TorrentResult[]; @@ -23,6 +29,14 @@ export async function cachedSearch( if (hit && Date.now() - hit.at < TTL_MS) return hit.results; const results = await source.search(query, opts); + // Re-inserting rather than overwriting keeps the map's iteration order equal + // to fetch order, so the entry that gives way below is always the oldest. + cache.delete(k); cache.set(k, { at: Date.now(), results }); + while (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } return results; } diff --git a/src/sources/eztv.test.ts b/src/sources/eztv.test.ts new file mode 100644 index 00000000..6bbbe520 --- /dev/null +++ b/src/sources/eztv.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFetch = vi.fn(); + +vi.mock("../util/net", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchResilient: mockFetch }; +}); + +const ok = (torrents: unknown[]): Response => + ({ ok: true, status: 200, json: async () => ({ torrents }) }) as unknown as Response; + +function row(title: string, hash: string, extra: Record = {}) { + return { + title, + hash, + magnet_url: `magnet:?xt=urn:btih:${hash}&dn=${encodeURIComponent(title)}`, + imdb_id: "111", + seeds: 10, + peers: 1, + size_bytes: "1000", + date_released_unix: 1_700_000_000, + ...extra, + }; +} + +// Every request the source makes goes through this, keyed on what the API +// actually reads: the page number and the imdb_id. +function respond(reply: (page: number, imdbId: string | null) => unknown[] | number): void { + mockFetch.mockImplementation(async (url: string) => { + const params = new URL(String(url)).searchParams; + const out = reply(Number(params.get("page")), params.get("imdb_id")); + // A number stands for an HTTP status the source has to deal with itself. + if (typeof out === "number") return { ok: false, status: out } as unknown as Response; + return ok(out); + }); +} + +const urls = (): string[] => mockFetch.mock.calls.map((c) => String(c[0])); + +// The recent-feed index is shared across queries, so each test starts from a +// fresh module rather than a leftover one. +async function freshEztv(): Promise { + vi.resetModules(); + return (await import("./eztv")).eztv; +} + +const JUDY = row("Judy Justice S04E79 1080p WEB h264", "a".repeat(40)); +const JUDY_OLD = row("Judy Justice S01E01 720p WEB h264", "b".repeat(40)); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("eztv search", () => { + it("asks for one page and nothing else when the query is empty", async () => { + respond(() => [JUDY]); + const eztv = await freshEztv(); + + const res = await eztv.search(""); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(urls()[0]).toContain("page=1"); + expect(urls()[0]).not.toContain("imdb_id"); + expect(res).toHaveLength(1); + expect(res[0]!.source).toBe("eztv"); + }); + + it("finds the show in the recent feed, then asks for its whole catalogue", async () => { + // The feed only reaches back a couple of days; S01E01 is only ever going to + // come back from the imdb_id query. + respond((page, imdbId) => { + if (imdbId === "111") return [JUDY, JUDY_OLD]; + return page === 1 ? [JUDY] : []; + }); + const eztv = await freshEztv(); + + const res = await eztv.search("judy justice"); + + expect(urls().some((u) => u.includes("imdb_id=111"))).toBe(true); + expect(res.map((r) => r.infoHash).sort()).toEqual(["a".repeat(40), "b".repeat(40)]); + expect(res.map((r) => r.name)).toContain("Judy Justice S01E01 720p WEB h264"); + }); + + it("never asks for a catalogue when nothing in the feed matches", async () => { + respond(() => [JUDY]); + const eztv = await freshEztv(); + + await expect(eztv.search("south park")).resolves.toEqual([]); + expect(urls().every((u) => !u.includes("imdb_id"))).toBe(true); + }); + + it("narrows rather than fails when a deeper page is unavailable", async () => { + respond((page, imdbId) => { + if (imdbId === "111") return [JUDY, JUDY_OLD]; + if (page === 1) return [JUDY]; + if (page === 4) return 503; + return []; + }); + const eztv = await freshEztv(); + + const res = await eztv.search("judy justice"); + + expect(res.length).toBeGreaterThan(0); + }); + + it("still surfaces the error when the first page is unavailable", async () => { + respond((page) => (page === 1 ? 503 : [])); + const eztv = await freshEztv(); + + await expect(eztv.search("judy justice")).rejects.toThrow(/503/); + }); + + it("reuses the recent feed across queries instead of refetching it", async () => { + respond((page, imdbId) => { + if (imdbId === "111") return [JUDY, JUDY_OLD]; + return page === 1 ? [JUDY] : []; + }); + const eztv = await freshEztv(); + + await eztv.search("judy justice"); + const afterFirst = mockFetch.mock.calls.length; + await eztv.search("judy justice 1080p"); + + // Only the catalogue request, never the ten index pages again. + expect(mockFetch.mock.calls.length - afterFirst).toBe(1); + }); +}); diff --git a/src/sources/eztv.ts b/src/sources/eztv.ts index 7e346143..7389cf76 100644 --- a/src/sources/eztv.ts +++ b/src/sources/eztv.ts @@ -4,11 +4,33 @@ import type { SearchOptions, Source, TorrentResult } from "./types"; const API = "https://eztvx.to/api/get-torrents"; +// EZTV's API takes no text query. limit, page and imdb_id are the only inputs +// it reads -- keywords, q, search and title are accepted and ignored, every one +// of them answering with the same unfiltered latest page -- and the HTML search +// route is behind Cloudflare on every mirror (eztvx.to, eztv.re, eztv.wf, +// eztv.tf, eztv.yt, eztv1.xyz all answer /search/ with 403 "Just a moment"). +// So a show name has to become an imdb_id before EZTV can answer it, and the +// only place to get that mapping without reaching for another service is EZTV's +// own feed: every row carries its show's imdb_id. Match the query against the +// recent feed, then ask for the shows it matched by id, which returns the whole +// catalogue rather than the couple of days the feed itself spans. +const PAGE_LIMIT = 100; // above 100 the API quietly answers with 30 +const INDEX_PAGES = 10; // ~1000 rows, ~250 distinct shows, ~2.5 days of releases +const MAX_SHOWS = 2; +const MAX_RESULTS = 100; // one API page's worth, like every other query here + +// The recent feed is the same for every query, so it is fetched once and shared +// instead of per query, on the same five-minute life the per-query cache in +// cache.ts uses. +const INDEX_TTL_MS = 5 * 60 * 1000; +let index: { at: number; rows: EztvTorrent[] } | null = null; + interface EztvTorrent { title?: string; filename?: string; hash?: string; magnet_url?: string; + imdb_id?: string; seeds?: number; peers?: number; size_bytes?: string | number; @@ -18,35 +40,107 @@ interface EztvResponse { torrents?: EztvTorrent[]; } -async function search(query: string, opts: SearchOptions = {}): Promise { - if (query.trim()) return []; - - const res = await fetchResilient(`${API}?limit=100&page=1`, { +async function fetchPage( + params: Record, + opts: SearchOptions, + retries: number, +): Promise { + const qs = new URLSearchParams({ limit: String(PAGE_LIMIT), ...params }); + const res = await fetchResilient(`${API}?${qs.toString()}`, { headers: { "User-Agent": USER_AGENT }, signal: opts.signal, - retries: 1, + retries, }); if (!res.ok) throw new HttpError(res.status, `EZTV returned ${res.status}`); - const json = (await res.json()) as EztvResponse; - const out: TorrentResult[] = []; - for (const t of json.torrents ?? []) { - const hash = (t.hash ?? "").toLowerCase(); - const name = t.title || t.filename || hash; - const magnet = t.magnet_url || (hash ? buildMagnet(hash, name) : ""); - if (!magnet || !hash) continue; - out.push({ - infoHash: hash, - name, - sizeBytes: Number(t.size_bytes ?? 0) || 0, - seeders: t.seeds ?? 0, - leechers: t.peers ?? 0, - source: "eztv", - magnet, - added: t.date_released_unix, - }); + return json.torrents ?? []; +} + +async function recentIndex(opts: SearchOptions): Promise { + const now = Date.now(); + if (index && now - index.at < INDEX_TTL_MS) return index.rows; + + // Page 1 is the request EZTV has always made here, and it still decides + // whether the source is reachable at all. The deeper pages only widen the + // window, so one of them failing narrows the search instead of breaking it. + const pages = Array.from({ length: INDEX_PAGES }, (_, i) => + fetchPage({ page: String(i + 1) }, opts, i === 0 ? 1 : 0), + ); + const guarded = pages.map((p, i) => (i === 0 ? p : p.catch(() => [] as EztvTorrent[]))); + const rows = (await Promise.all(guarded)).flat(); + index = { at: now, rows }; + return rows; +} + +function haystack(t: EztvTorrent): string { + return `${t.title ?? ""} ${t.filename ?? ""}`.toLowerCase(); +} + +function matches(t: EztvTorrent, tokens: string[]): boolean { + const name = haystack(t); + return tokens.every((token) => name.includes(token)); +} + +function toResult(t: EztvTorrent): TorrentResult | null { + const hash = (t.hash ?? "").toLowerCase(); + const name = t.title || t.filename || hash; + const magnet = t.magnet_url || (hash ? buildMagnet(hash, name) : ""); + if (!magnet || !hash) return null; + return { + infoHash: hash, + name, + sizeBytes: Number(t.size_bytes ?? 0) || 0, + seeders: t.seeds ?? 0, + leechers: t.peers ?? 0, + source: "eztv", + magnet, + added: t.date_released_unix, + }; +} + +// A show's own catalogue overlaps the recent feed by definition, so the same +// hash arrives twice; keep the first and stop there. +function toResults(rows: EztvTorrent[]): TorrentResult[] { + const byHash = new Map(); + for (const row of rows) { + const r = toResult(row); + if (r && !byHash.has(r.infoHash)) byHash.set(r.infoHash, r); + } + return [...byHash.values()]; +} + +function showIdsOf(hits: EztvTorrent[]): string[] { + const ids: string[] = []; + for (const t of hits) { + const id = (t.imdb_id ?? "").trim(); + if (!id || id === "0" || ids.includes(id)) continue; + ids.push(id); + if (ids.length === MAX_SHOWS) break; } - return out; + return ids; +} + +async function search(query: string, opts: SearchOptions = {}): Promise { + const q = query.trim(); + // The empty query is the popular list, and one page is what it has always + // been. Widening the index there would make every startup ten times heavier + // for a view that does not need it. + if (!q) return toResults(await fetchPage({ page: "1" }, opts, 1)); + + const tokens = q.toLowerCase().split(/\s+/).filter(Boolean); + const recent = await recentIndex(opts); + const hits = recent.filter((t) => matches(t, tokens)); + + const catalogues = await Promise.all( + showIdsOf(hits).map((imdb_id) => + fetchPage({ page: "1", imdb_id }, opts, 0).catch(() => [] as EztvTorrent[]), + ), + ); + + const rows = [...hits, ...catalogues.flat().filter((t) => matches(t, tokens))]; + return toResults(rows) + .sort((a, b) => b.seeders - a.seeders) + .slice(0, MAX_RESULTS); } export const eztv: Source = { diff --git a/src/sources/magnet.test.ts b/src/sources/magnet.test.ts index 47af92c9..734ea2fd 100644 --- a/src/sources/magnet.test.ts +++ b/src/sources/magnet.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { parseMagnet, parseInput, isInfoHash, normalizeInfoHash, buildMagnet } from "./magnet"; +import { + parseMagnet, + parseInput, + isInfoHash, + normalizeInfoHash, + buildMagnet, + mergeMagnetTrackers, +} from "./magnet"; describe("parseMagnet", () => { it("keeps a full 40-char hex info hash", () => { @@ -111,3 +118,43 @@ describe("parseInput", () => { expect(parseInput("")).toBeNull(); }); }); + +describe("mergeMagnetTrackers", () => { + const hash = "abcdef0123456789abcdef0123456789abcdef01"; + const own = "udp://private.example:6969/announce/passkey"; + + it("appends only the trackers the primary is missing", () => { + const primary = buildMagnet(hash, "X"); + const merged = mergeMagnetTrackers(primary, [buildMagnet(hash, "X", [own])]); + const tr = new URL(merged).searchParams.getAll("tr"); + expect(tr).toContain(own); + expect(new Set(tr).size).toBe(tr.length); + expect(merged.startsWith(primary)).toBe(true); + }); + + it("returns the primary untouched when the others add nothing", () => { + const primary = buildMagnet(hash, "X"); + expect(mergeMagnetTrackers(primary, [buildMagnet(hash, "X")])).toBe(primary); + expect(mergeMagnetTrackers(primary, [])).toBe(primary); + }); + + it("keeps parameters buildMagnet does not write", () => { + const primary = `magnet:?xt=urn:btih:${hash}&dn=X&xl=1234`; + const merged = mergeMagnetTrackers(primary, [buildMagnet(hash, "X", [own])]); + expect(new URL(merged).searchParams.get("xl")).toBe("1234"); + expect(new URL(merged).searchParams.getAll("tr")).toContain(own); + }); + + it("leaves anything that is not a magnet exactly as it came", () => { + expect(mergeMagnetTrackers("not a magnet", [buildMagnet(hash, "X", [own])])).toBe( + "not a magnet", + ); + expect(mergeMagnetTrackers("", [])).toBe(""); + }); + + it("ignores others that are not magnets", () => { + const primary = buildMagnet(hash, "X"); + expect(mergeMagnetTrackers(primary, ["nonsense", ""])).toBe(primary); + }); +}); + diff --git a/src/sources/magnet.ts b/src/sources/magnet.ts index 99079433..bf227998 100644 --- a/src/sources/magnet.ts +++ b/src/sources/magnet.ts @@ -96,3 +96,37 @@ export function parseInput(input: string): ParsedMagnet | null { const infoHash = normalizeInfoHash(s); return { infoHash, name: infoHash, magnet: buildMagnet(infoHash, infoHash) }; } + +function trackersOf(magnet: string): string[] | null { + const s = magnet.trim(); + if (!/^magnet:\?/i.test(s)) return null; + try { + return new URL(s).searchParams.getAll("tr").map((t) => t.trim()).filter(Boolean); + } catch { + return null; + } +} + +// A magnet's announce list is part of what a source knows about the torrent, so +// when the same infohash arrives from several sources their lists get folded +// together rather than the extras thrown away. `primary` is returned byte for +// byte with only the trackers it is missing appended, which keeps every other +// parameter it carries (xl, ws, so) intact — unlike rebuilding through +// buildMagnet(). Order does not matter to a client: every tr in a magnet ends up +// in one announce list and all of them are contacted. +export function mergeMagnetTrackers(primary: string, others: string[]): string { + const own = trackersOf(primary); + if (!own) return primary; + const seen = new Set(own); + const extra: string[] = []; + for (const other of others) { + for (const url of trackersOf(other) ?? []) { + if (seen.has(url)) continue; + seen.add(url); + extra.push(url); + } + } + if (extra.length === 0) return primary; + return primary.trim() + extra.map((t) => `&tr=${encodeURIComponent(t)}`).join(""); +} + diff --git a/src/sources/nyaa.ts b/src/sources/nyaa.ts index 3c449ca4..cdfe067b 100644 --- a/src/sources/nyaa.ts +++ b/src/sources/nyaa.ts @@ -1,7 +1,7 @@ import { fetchResilient, HttpError, USER_AGENT } from "../util/net"; import { buildMagnet } from "./magnet"; import { unescapeEntities } from "./rss"; -import { parseSize } from "../util/format"; +import { parseSize, parseUnixSeconds } from "../util/format"; import type { SearchOptions, Source, SourceId, TorrentResult } from "./types"; const BASE = "https://nyaa.si/"; @@ -45,7 +45,7 @@ async function search( leechers: Number.isFinite(leechers) ? leechers : 0, source: sourceId, magnet: buildMagnet(infoHash, name), - added: dateStr ? new Date(dateStr).getTime() / 1000 : undefined, + added: parseUnixSeconds(dateStr), }); } return out; diff --git a/src/sources/subsplease.ts b/src/sources/subsplease.ts index d4754e4b..ac447f40 100644 --- a/src/sources/subsplease.ts +++ b/src/sources/subsplease.ts @@ -1,5 +1,6 @@ import { fetchResilient, HttpError, USER_AGENT } from "../util/net"; import { parseMagnet } from "./magnet"; +import { parseUnixSeconds } from "../util/format"; import type { SearchOptions, Source, TorrentResult } from "./types"; const API = "https://subsplease.org/api/"; @@ -60,7 +61,7 @@ async function search(query: string, opts: SearchOptions = {}): Promise & { source: SourceId }): TorrentResult { + return { + infoHash: HASH, + name: "Some Release 1080p", + sizeBytes: 1_000_000, + seeders: 0, + leechers: 0, + magnet: buildMagnet(HASH, "Some Release 1080p"), + ...over, + }; +} + +function trackers(magnet: string): string[] { + return new URL(magnet).searchParams.getAll("tr"); +} + +describe("dedupeResults", () => { + it("leaves distinct hashes alone", () => { + const other = "0123456789abcdef0123456789abcdef01234567"; + const out = dedupeResults([row({ source: "nyaa" }), row({ source: "yts", infoHash: other })]); + expect(out).toHaveLength(2); + }); + + it("keeps the healthiest row's own fields", () => { + const out = dedupeResults([ + row({ source: "tpb-movies", seeders: 3, name: "TPB name" }), + row({ source: "x1337-movies", seeders: 90, name: "1337x name" }), + ]); + expect(out).toHaveLength(1); + expect(out[0]!.seeders).toBe(90); + expect(out[0]!.source).toBe("x1337-movies"); + expect(out[0]!.name).toBe("1337x name"); + }); + + it("carries the losing row's trackers into the surviving magnet", () => { + // The row 1337x returns carries the torrent's own announce list; the row + // buildMagnet() writes carries only torlink's public defaults. Whichever + // wins on seeders, both lists have to survive. + const own = "udp://private.example:6969/announce/passkey"; + const out = dedupeResults([ + row({ source: "tpb-movies", seeders: 90 }), + row({ source: "x1337-movies", seeders: 3, magnet: buildMagnet(HASH, "x", [own]) }), + ]); + expect(out).toHaveLength(1); + expect(out[0]!.seeders).toBe(90); + expect(trackers(out[0]!.magnet)).toContain(own); + expect(trackers(out[0]!.magnet)).toContain("udp://tracker.opentrackr.org:1337/announce"); + }); + + it("adds no duplicate trackers and no duplicate rows for three sources", () => { + const a = "udp://a.example:6969/announce"; + const b = "udp://b.example:6969/announce"; + const out = dedupeResults([ + row({ source: "tpb-movies", seeders: 1, magnet: buildMagnet(HASH, "x", [a]) }), + row({ source: "yts", seeders: 5, magnet: buildMagnet(HASH, "x", [b]) }), + row({ source: "x1337-movies", seeders: 2, magnet: buildMagnet(HASH, "x", [a]) }), + ]); + expect(out).toHaveLength(1); + const tr = trackers(out[0]!.magnet); + expect(tr).toContain(a); + expect(tr).toContain(b); + expect(new Set(tr).size).toBe(tr.length); + }); + + it("backfills numFiles and added only where the winner has none", () => { + const out = dedupeResults([ + row({ source: "yts", seeders: 90, added: 1_700_000_000 }), + row({ source: "eztv", seeders: 3, numFiles: 7, added: 1_600_000_000 }), + ]); + expect(out[0]!.numFiles).toBe(7); + expect(out[0]!.added).toBe(1_700_000_000); + }); + + it("keeps the first row when seeders tie", () => { + const out = dedupeResults([ + row({ source: "nyaa", seeders: 10 }), + row({ source: "subsplease", seeders: 10 }), + ]); + expect(out[0]!.source).toBe("nyaa"); + }); +}); diff --git a/src/ui/dedupe.ts b/src/ui/dedupe.ts new file mode 100644 index 00000000..91904429 --- /dev/null +++ b/src/ui/dedupe.ts @@ -0,0 +1,35 @@ +import { mergeMagnetTrackers } from "../sources/magnet"; +import type { TorrentResult } from "../sources/types"; + +// The same torrent reaches the list from several sources at once, and only one +// row should survive. The healthiest row still wins — its seeder count is the +// one worth showing — but the rows it beats are folded into it instead of being +// dropped: +// +// - their announce lists. 1337x and EZTV hand back the torrent's own +// trackers, while rows built by buildMagnet() carry only torlink's public +// defaults, so keeping the higher-seeder row alone can trade a working +// announce list for a generic one. Same loss #146 fixed for a .torrent +// file's own trackers. +// - numFiles and added, but only where the winner has none. A field the +// winner never reported is a gap, not a decision. +// +// Everything else stays the winner's, including its magnet URI byte for byte. +function merge(a: TorrentResult, b: TorrentResult): TorrentResult { + const [win, lost] = b.seeders > a.seeders ? [b, a] : [a, b]; + return { + ...win, + magnet: mergeMagnetTrackers(win.magnet, [lost.magnet]), + numFiles: win.numFiles ?? lost.numFiles, + added: win.added ?? lost.added, + }; +} + +export function dedupeResults(list: TorrentResult[]): TorrentResult[] { + const byHash = new Map(); + for (const r of list) { + const existing = byHash.get(r.infoHash); + byHash.set(r.infoHash, existing ? merge(existing, r) : r); + } + return [...byHash.values()]; +} diff --git a/src/ui/hooks/useConcurrentSearch.ts b/src/ui/hooks/useConcurrentSearch.ts index d41c0590..c616325d 100644 --- a/src/ui/hooks/useConcurrentSearch.ts +++ b/src/ui/hooks/useConcurrentSearch.ts @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; import { SOURCES } from "../../sources/registry"; import { cachedSearch } from "../../sources/cache"; +import { dedupeResults } from "../dedupe"; +import { defaultOrder } from "../sort"; import { HttpError } from "../../util/net"; import type { SourceId, TorrentResult } from "../../sources/types"; @@ -30,24 +32,6 @@ function blankPerSource(loading: boolean): Record { return out; } -function dedupe(list: TorrentResult[]): TorrentResult[] { - const byHash = new Map(); - for (const r of list) { - const existing = byHash.get(r.infoHash); - if (!existing || r.seeders > existing.seeders) byHash.set(r.infoHash, r); - } - return [...byHash.values()]; -} - -// torlink's default ordering: healthiest first. The results view can re-sort -// on demand (the `s` key), and its "none"/default state preserves this order. -function defaultOrder(list: TorrentResult[]): TorrentResult[] { - return list.sort((a, b) => { - if (b.seeders !== a.seeders) return b.seeders - a.seeders; - return (b.added ?? 0) - (a.added ?? 0); - }); -} - function idleState(): ConcurrentSearchState { return { results: [], @@ -78,7 +62,7 @@ export function useConcurrentSearch(query: string): ConcurrentSearchState { const flush = (): void => { setState({ - results: defaultOrder(dedupe(collected.slice())), + results: defaultOrder(dedupeResults(collected.slice())), perSource: { ...per }, loading: done < SOURCES.length, done, diff --git a/src/ui/sort.test.ts b/src/ui/sort.test.ts index d733bade..66ec7710 100644 --- a/src/ui/sort.test.ts +++ b/src/ui/sort.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { nextSort, sortResults, sortArrow, SORT_CYCLE } from "./sort"; +import { defaultOrder, nextSort, sortResults, sortArrow, SORT_CYCLE } from "./sort"; import type { Sort } from "./sort"; import type { SourceId, TorrentResult } from "../sources/types"; @@ -152,3 +152,26 @@ describe("sortResults", () => { expect(ids(list)).toEqual(before); }); }); + +describe("defaultOrder", () => { + it("orders by seeders, then newest first", () => { + const ordered = defaultOrder([ + r({ infoHash: "few", seeders: 2, added: 300 }), + r({ infoHash: "older", seeders: 8, added: 100 }), + r({ infoHash: "newer", seeders: 8, added: 200 }), + r({ infoHash: "undated", seeders: 8 }), + ]); + expect(ids(ordered)).toEqual(["newer", "older", "undated", "few"]); + }); + + // The TUI and the headless `search` command both order through this, so a + // change here has to stay one change rather than two that drift. + it("puts a missing added last among equal seeders", () => { + const ordered = defaultOrder([ + r({ infoHash: "undated", seeders: 5 }), + r({ infoHash: "dated", seeders: 5, added: 1 }), + ]); + expect(ids(ordered)).toEqual(["dated", "undated"]); + }); +}); + diff --git a/src/ui/sort.ts b/src/ui/sort.ts index df03acb9..a305d050 100644 --- a/src/ui/sort.ts +++ b/src/ui/sort.ts @@ -45,6 +45,16 @@ export function sortLabel(sort: Sort): string { return `${sort.field} ${sortArrow(sort.dir)}`; } +// torlink's default ordering: healthiest first. This is what "none" above +// preserves, and it is what both the TUI and the headless `search` command +// hand their merged result list to, so the two orders can never drift. +export function defaultOrder(list: TorrentResult[]): TorrentResult[] { + return list.sort((a, b) => { + if (b.seeders !== a.seeders) return b.seeders - a.seeders; + return (b.added ?? 0) - (a.added ?? 0); + }); +} + export function sortResults(list: TorrentResult[], sort: Sort): TorrentResult[] { const arr = list.slice(); if (sort === "none") return arr; diff --git a/src/util/clipboard.test.ts b/src/util/clipboard.test.ts index 95a52fde..8ea254b9 100644 --- a/src/util/clipboard.test.ts +++ b/src/util/clipboard.test.ts @@ -5,17 +5,68 @@ const spawn = vi.fn(); vi.mock("node:child_process", () => ({ spawn })); +interface FakeProc extends EventEmitter { + stdin: { end: (value?: string) => void }; + stdout: EventEmitter; + kill: () => void; +} + +function mockSpawn(code: number): void { + spawn.mockImplementation(() => { + const proc = new EventEmitter() as FakeProc; + proc.stdout = new EventEmitter(); + proc.kill = vi.fn(); + proc.stdin = { end: vi.fn(() => queueMicrotask(() => proc.emit("exit", code))) }; + return proc; + }); +} + +// OSC 52 goes to the terminal, not to a child process, so the test watches the +// tty rather than node:child_process. +function captureTty(isTTY: boolean): { written: string[]; restore: () => void } { + const written: string[] = []; + const err = process.stderr as unknown as { isTTY?: boolean; write: unknown }; + const out = process.stdout as unknown as { isTTY?: boolean }; + const prev = { errIsTTY: err.isTTY, errWrite: err.write, outIsTTY: out.isTTY }; + err.isTTY = isTTY; + out.isTTY = isTTY; + err.write = (chunk: unknown) => { + written.push(String(chunk)); + return true; + }; + return { + written, + restore: () => { + err.isTTY = prev.errIsTTY; + err.write = prev.errWrite; + out.isTTY = prev.outIsTTY; + }, + }; +} + +async function onPlatform(platform: string, run: () => Promise): Promise { + const original = process.platform; + Object.defineProperty(process, "platform", { value: platform }); + try { + return await run(); + } finally { + Object.defineProperty(process, "platform", { value: original }); + vi.resetModules(); + vi.unstubAllEnvs(); + spawn.mockReset(); + } +} + +function decodeOsc52(seq: string): string { + const body = seq.slice(seq.indexOf(";c;") + 3, seq.lastIndexOf("\u0007")); + return Buffer.from(body, "base64").toString("utf8"); +} + describe("writeClipboard", () => { it("writes text to the first available Linux clipboard command", async () => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "linux" }); - try { + await onPlatform("linux", async () => { spawn.mockImplementation((cmd: string) => { - const proc = new EventEmitter() as EventEmitter & { - stdin: { end: (value: string) => void }; - stdout: EventEmitter; - kill: () => void; - }; + const proc = new EventEmitter() as FakeProc; proc.stdout = new EventEmitter(); proc.kill = vi.fn(); proc.stdin = { @@ -33,10 +84,104 @@ describe("writeClipboard", () => { expect(spawn.mock.results[0]?.value.stdin.end).toHaveBeenCalledWith( "magnet:?xt=urn:btih:abc", ); + }); + }); +}); + +describe("writeClipboard OSC 52 fallback", () => { + it("asks the terminal when no Linux clipboard command works", async () => { + const tty = captureTty(true); + try { + await onPlatform("linux", async () => { + vi.stubEnv("SSH_TTY", ""); + vi.stubEnv("SSH_CONNECTION", ""); + vi.stubEnv("TMUX", ""); + mockSpawn(1); + const { writeClipboard } = await import("./clipboard"); + + await expect(writeClipboard("magnet:?xt=urn:btih:abc")).resolves.toBe(true); + // wl-copy, xclip and xsel were all tried first. + expect(spawn).toHaveBeenCalledTimes(3); + expect(tty.written.join("")).toBe( + "\u001b]52;c;bWFnbmV0Oj94dD11cm46YnRpaDphYmM=\u0007", + ); + expect(decodeOsc52(tty.written.join(""))).toBe("magnet:?xt=urn:btih:abc"); + }); } finally { - Object.defineProperty(process, "platform", { value: originalPlatform }); - vi.resetModules(); - spawn.mockReset(); + tty.restore(); + } + }); + + it("stays quiet when the platform command already worked", async () => { + const tty = captureTty(true); + try { + await onPlatform("darwin", async () => { + vi.stubEnv("SSH_TTY", ""); + vi.stubEnv("SSH_CONNECTION", ""); + mockSpawn(0); + const { writeClipboard } = await import("./clipboard"); + + await expect(writeClipboard("magnet:?xt=urn:btih:abc")).resolves.toBe(true); + expect(spawn).toHaveBeenCalledWith("pbcopy", [], { windowsHide: true }); + expect(tty.written).toEqual([]); + }); + } finally { + tty.restore(); + } + }); + + it("prefers the terminal over the remote clipboard inside an SSH session", async () => { + const tty = captureTty(true); + try { + await onPlatform("linux", async () => { + vi.stubEnv("SSH_TTY", "/dev/pts/0"); + vi.stubEnv("TMUX", ""); + mockSpawn(0); // wl-copy would have "worked" -- on the wrong machine + const { writeClipboard } = await import("./clipboard"); + + await expect(writeClipboard("magnet:?xt=urn:btih:abc")).resolves.toBe(true); + expect(spawn).not.toHaveBeenCalled(); + expect(decodeOsc52(tty.written.join(""))).toBe("magnet:?xt=urn:btih:abc"); + }); + } finally { + tty.restore(); + } + }); + + it("wraps the sequence in a DCS passthrough inside tmux", async () => { + const tty = captureTty(true); + try { + await onPlatform("linux", async () => { + vi.stubEnv("SSH_TTY", ""); + vi.stubEnv("SSH_CONNECTION", ""); + vi.stubEnv("TMUX", "/run/user/1000/tmux-1000/default,1234,0"); + mockSpawn(1); + const { writeClipboard } = await import("./clipboard"); + + await expect(writeClipboard("hi")).resolves.toBe(true); + expect(tty.written.join("")).toBe("\u001bPtmux;\u001b\u001b]52;c;aGk=\u0007\u001b\\"); + }); + } finally { + tty.restore(); + } + }); + + it("still reports failure when there is no terminal to ask", async () => { + const tty = captureTty(false); + try { + await onPlatform("linux", async () => { + vi.stubEnv("SSH_TTY", ""); + vi.stubEnv("SSH_CONNECTION", ""); + vi.stubEnv("TMUX", ""); + mockSpawn(1); + const { writeClipboard } = await import("./clipboard"); + + await expect(writeClipboard("magnet:?xt=urn:btih:abc")).resolves.toBe(false); + expect(tty.written).toEqual([]); + }); + } finally { + tty.restore(); } }); }); + diff --git a/src/util/clipboard.ts b/src/util/clipboard.ts index d5ed0295..5780d010 100644 --- a/src/util/clipboard.ts +++ b/src/util/clipboard.ts @@ -83,7 +83,7 @@ export async function readClipboard(): Promise { return ""; } -export async function writeClipboard(text: string): Promise { +async function writeNative(text: string): Promise { if (process.platform === "win32") { return write( "powershell", @@ -99,3 +99,60 @@ export async function writeClipboard(text: string): Promise { } return false; } + +// OSC 52 asks the terminal emulator itself to set the clipboard: +// ESC ] 52 ; c ; BEL. It needs no helper binary, which is the whole +// point — it is the one mechanism that still works when wl-copy, xclip and xsel +// are all absent, and the only one that reaches the clipboard of the machine the +// user is actually sitting at when torlink runs over SSH. torlink already knows +// this sequence from the other direction: stripControl() in util/format.ts +// exists so a hijacked source cannot smuggle one in through a name or a magnet. +// +// A terminal never acknowledges OSC 52, so `true` here means the request +// reached the terminal, not that the clipboard now holds the text. That is the +// honest limit of the mechanism, and it beats telling someone the copy failed +// when it almost certainly did not. + +// Terminals cap the payload they will accept. A silently truncated paste is +// worse than no paste, so anything past the cap is left to the helper binaries. +// A magnet with every default tracker is well under 2 KB. +const OSC52_MAX_BASE64 = 100_000; + +function ttyStream(): NodeJS.WriteStream | null { + if (process.stderr.isTTY) return process.stderr; + if (process.stdout.isTTY) return process.stdout; + return null; +} + +function writeOsc52(text: string): boolean { + const stream = ttyStream(); + if (!stream) return false; + const payload = Buffer.from(text, "utf8").toString("base64"); + if (payload.length > OSC52_MAX_BASE64) return false; + const seq = `\u001b]52;c;${payload}\u0007`; + try { + // tmux drops an application's OSC 52 unless set-clipboard is `on`, and its + // default is `external`. Wrapping it in a DCS passthrough hands the sequence + // to the outer terminal whatever that setting is. TMUX is set by tmux itself, + // so this never fires anywhere else. + stream.write(process.env.TMUX ? `\u001bPtmux;\u001b${seq}\u001b\\` : seq); + return true; + } catch { + return false; + } +} + +// Over SSH the helper binaries set the clipboard of the machine torlink is +// running on, not the one in front of the user, so the magnet lands somewhere +// they can never paste from — a success that is really a failure. Where that is +// the situation, ask the terminal first. +function isRemoteSession(): boolean { + return Boolean(process.env.SSH_TTY || process.env.SSH_CONNECTION); +} + +export async function writeClipboard(text: string): Promise { + if (isRemoteSession() && writeOsc52(text)) return true; + if (await writeNative(text)) return true; + return writeOsc52(text); +} + diff --git a/src/util/format.test.ts b/src/util/format.test.ts index 3e352ade..ff86c3b6 100644 --- a/src/util/format.test.ts +++ b/src/util/format.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { formatBytes, parseSize, + parseUnixSeconds, formatBytesPerSec, formatCount, formatRelative, @@ -118,3 +119,33 @@ describe("stripControl", () => { expect(stripControl("")).toBe(""); }); }); + +describe("parseUnixSeconds", () => { + it("parses the date shapes the feeds actually send", () => { + // nyaa.si RSS pubDate and the SubsPlease API release_date are both RFC 822. + expect(parseUnixSeconds("Thu, 27 Aug 2026 11:49:25 -0000")).toBe( + Date.UTC(2026, 7, 27, 11, 49, 25) / 1000, + ); + expect(parseUnixSeconds("2026-08-27T11:49:25Z")).toBe( + Date.UTC(2026, 7, 27, 11, 49, 25) / 1000, + ); + }); + + it("returns undefined rather than NaN for anything unreadable", () => { + for (const bad of [undefined, "", " ", "not a date", "0000-13-45", "Thu, 32 Xxx"]) { + expect(parseUnixSeconds(bad)).toBeUndefined(); + } + }); + + it("never yields a value that breaks a sort comparator", () => { + // NaN is a number, so `added ?? 0` would pass it through, and a comparator + // that returns NaN leaves Array.prototype.sort implementation-defined. + const added = ["Thu, 27 Aug 2026 11:49:25 -0000", "not a date", undefined].map( + parseUnixSeconds, + ); + for (const a of added) expect(a === undefined || Number.isFinite(a)).toBe(true); + const cmp = (a?: number, b?: number): number => (b ?? 0) - (a ?? 0); + for (const a of added) for (const b of added) expect(Number.isNaN(cmp(a, b))).toBe(false); + }); +}); + diff --git a/src/util/format.ts b/src/util/format.ts index da85c078..5b941177 100644 --- a/src/util/format.ts +++ b/src/util/format.ts @@ -28,6 +28,20 @@ export function parseSize(s: string): number { return Math.round(parseFloat(m[1]!) * (SIZE_UNITS[m[2]!.toUpperCase()] ?? 1)); } +// Feed timestamps arrive as free text: an RSS pubDate, a JSON release_date. +// Date.parse returns NaN for anything it cannot read, and NaN is a number, so +// the `added ?? 0` guards downstream never catch it — it flows straight into a +// sort comparator, and a comparator that returns NaN makes Array.prototype.sort +// implementation-defined for the whole array. Returning undefined keeps `added` +// honest: absent, rather than zero or NaN. Same instinct as the Number.isFinite +// guards nyaa.ts already applies to its seeders and leechers. +export function parseUnixSeconds(value?: string): number | undefined { + if (!value) return undefined; + const ms = new Date(value).getTime(); + return Number.isFinite(ms) ? ms / 1000 : undefined; +} + + export function formatBytesPerSec(bytes?: number): string { if (bytes === undefined || !Number.isFinite(bytes) || bytes <= 0) return ""; const units = ["B/s", "KB/s", "MB/s", "GB/s"];