├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .husky └── pre-push ├── .npmrc ├── LICENSE ├── README.md ├── babel.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── rollup.conf.js ├── sites.json └── src ├── XFetch.js ├── clientUtils.js ├── globalSettingsManager.js ├── index.js ├── meta.js ├── profileManager.js ├── settings.js ├── style.module.css ├── trackerUtils.js └── types ├── css.d.ts └── index.d.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | Chrome >= 55 2 | Firefox >= 53 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | quote_type = single 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/src 3 | !/test 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | require.resolve('@gera2ld/plaid/eslint'), 5 | 'plugin:prettier/recommended', 6 | ], 7 | settings: { 8 | 'import/resolver': { 9 | 'babel-module': {}, 10 | }, 11 | react: { 12 | pragma: 'VM', 13 | }, 14 | }, 15 | globals: { 16 | VM: true, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | /.idea 4 | /dist 5 | /.nyc_output 6 | /coverage 7 | /types 8 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | # #!/bin/sh 2 | # . "$(dirname "$0")/_/husky.sh" 3 | 4 | # npm run lint 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist = true 2 | strict-peer-dependencies = false 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 notmarek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Install | View Source 4 | 5 | ![https://ptpimg.me/6fh61t.png](https://ptpimg.me/6fh61t.png) 6 | ![https://ptpimg.me/5ujhi8.png](https://ptpimg.me/5ujhi8.png) 7 | 8 | This script let's you send torrents straight to your download client with a single button! 9 | 10 |

11 | 12 | ## **Limitations:** 13 | 14 | qBittorrent, Transmission, Deluge, ruTorrent, and Flood (this lets you use anything flood supports) are supported, if you want to see your client added leave a comment. 15 | 16 | ## **Supported sites:** 17 | 18 | **Gazelle:** PTP, GGn, AB, OPS, GPW, RED, JPS, TVV, Sugoi Music, iAnon, AR, UHDB, MTV, EMP, BTN, SC (Some of these are untested an may not work, please report so) 19 | 20 | **UNIT3D:** BLU, Aither, DT, Telly, TS, BHD (F3NIX fork) 21 | 22 | **Others:** TorrentLeech, AnilistBytes script 23 | 24 | ## **Changelog:** 25 | [2.3.1](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/b8ba70c85aadebfbe4536be23b6907e35e7c47c1/SendToClient.user.js) - Fixed a bug that killed the whole extension 26 | 27 | [2.3.0](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/61e0b340385905f03e0895ffdca42e52fea0ea54/SendToClient.user.js) - Extended ST button - let's you pick the profile on each torrent seperatly 28 | 29 | [2.2.5](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/987f099d7cbed678626214d10ae46185130db786/SendToClient.user.js) - BHD support 30 | 31 | [2.2.4](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/98663a9a84f4b05352eaa3a9bb78bf8fdb4fc312/SendToClient.user.js) - ruTorrent support 32 | 33 | [2.2.3](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/5bfd7a3a42a8faa64b9632671c500fc6995486e0/SendToClient.user.js) - KG support, minor code cleanup 34 | 35 | [2.2.2](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/3507cc15889ad03402ec3d113e302267a2ed04f8/SendToClient.user.js) - The script now only injects on sites, where it actually works. 36 | 37 | [2.2.1](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/79dc5d28acee6fc075954c86aee3f721a1e1d93a/SendToClient.user.js) - Added ability to pin profiles to sites, added support for TL 38 | 39 | [2.2.0](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/b9ecedd78f75f5b85c5dbeb7949d5d119cb68c30/SendToClient.user.js) - (qbit) Categories support! 40 | 41 | [2.1.2](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/6fd7299728af356b95782f62bc05d5ef7be3be4b/SendToClient.user.js) - Make FST Smarter - don't show on sites without FL, add need to accept 42 | 43 | [2.1.1](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/5f77d26a1681dfc451086276aeaea8f16099527c/SendToClient.user.js) - Added FST (Freeleech & Send) to Gazelle 44 | 45 | [2.1.0](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/7493fe573fbb08b05986b370479c42f21914023f/SendToClient.user.js) - UNIT3D support! - currently supported: BLU, Aither, DT, JPTV, Telly, TS 46 | 47 | [2.0.2](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/c5af7a9f65470e1d4dcaccf8b011f4b13d306e77/SendToClient.user.js) - Added DB9, SC, BTN 48 | 49 | [2.0.1](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/b64fc7adfc1ff805d47680538cc333196e3ccf9b/SendToClient.user.js) - Add support for AnilistBytes (my other AB specific UserScript) 50 | 51 | [2.0.0](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/83645715e62a99588c12baa6831de0589d3f8a89/SendToClient.user.js) - Huge update, the userscript is now detached from AB and works on most Gazelle based trackers! this change is breaking and you will need to configure the script from scratch. The source code was also moved to gihtub as i now use a more complicated workflow 52 | 53 |
54 | Previous versions (For animebytes only): 55 | 56 | [1.4.3](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/3c28507528cd01797abe5ed7db1c36dfc74ec03e/SendToClient.user.js) - fixed fixed an issue on OPS 57 | 58 | [1.4.2](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/2c8cd162170372618d5bfc64d4ed4f943443b28d/SendToClient.user.js) - fixed an issue on OPS 59 | 60 | [1.4.1](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/5f328456d61002f0ed3f5dc98a80ad23aa92e68a/SendToClient.user.js) - Added support for multiple other trackers, settings still reside here for now 61 | 62 | [1.4](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/67b71f84fdfc0a441f6987d87222e0fe8cd0fdb2/SendToClient.user.js) - Added support for Deluge 63 | 64 | [1.3](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/5ab94412d34b35a09effe1d101b55f9025a77453/SendToClient.user.js) - Added support for Flood 65 | 66 | [1.2](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/13e746d012af9f4231f55c19273a8e83aebb3dcb/SendToClient.user.js) - Added transmission support he script now let's specify the save path 67 | 68 | [1.1](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/7e302e52ba046354a462ab0009a4cfa5e36191a3/SendToClient.user.js) - Hotfix for compatibility with AnilistBytes 69 | 70 | [1.0](https://gist.github.com/notmarek/4f8fea8ae4e7cc524cba51a3594a128c/raw/2d1288fae418dc7f0409fb439f7c62ca3c43d1d8/SendToClient.user.js) - Original release 71 | 72 |
73 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: require.resolve('@gera2ld/plaid/config/babelrc-base'), 3 | presets: [ 4 | ], 5 | plugins: [ 6 | ['@babel/plugin-transform-react-jsx', { 7 | pragma: 'VM.h', // use 'VM.hm' if you don't need SVG support and don't want to call VM.m 8 | pragmaFrag: 'VM.Fragment', 9 | }], 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "es6", 5 | "moduleResolution": "node", 6 | "declaration": false, 7 | "allowSyntheticDefaultImports": true, 8 | "jsx": "preserve", 9 | "lib": [ 10 | "DOM", 11 | "ES6", 12 | "DOM.Iterable", 13 | "ScriptHost", 14 | "ESNext" 15 | ] 16 | }, 17 | "include": [ 18 | "src/**/*", 19 | "node_modules/@violentmonkey/types" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sendtoclient", 3 | "version": "2.3.1", 4 | "description": "", 5 | "author": "notmarek", 6 | "license": "MIT", 7 | "private": true, 8 | "engines": { 9 | "node": ">=16" 10 | }, 11 | "scripts": { 12 | "prepare": "husky install", 13 | "dev": "rollup -wc rollup.conf.js", 14 | "clean": "del-cli dist", 15 | "build:js": "NODE_ENV=production rollup -c rollup.conf.js", 16 | "prebuild": "run-s ci clean", 17 | "build": "cross-env NODE_ENV=production run-s build:js", 18 | "ci": "run-s lint", 19 | "lint": "eslint --ext .js ." 20 | }, 21 | "dependencies": { 22 | "@babel/runtime": "^7.18.9", 23 | "@violentmonkey/dom": "^2.1.3", 24 | "@violentmonkey/ui": "^0.7.6" 25 | }, 26 | "devDependencies": { 27 | "@babel/plugin-transform-react-jsx": "^7.18.10", 28 | "@gera2ld/plaid": "~2.5.6", 29 | "@gera2ld/plaid-rollup": "~2.5.6", 30 | "@violentmonkey/types": "^0.1.4", 31 | "del-cli": "^5.0.0", 32 | "eslint-config-prettier": "^8.5.0", 33 | "eslint-plugin-prettier": "^4.2.1", 34 | "husky": "^8.0.1", 35 | "prettier": "^2.7.1", 36 | "rollup-plugin-userscript": "^0.1.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /rollup.conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { getRollupPlugins } = require('@gera2ld/plaid'); 3 | 4 | const userscript = require('rollup-plugin-userscript'); 5 | const pkg = require('./package.json'); 6 | const env = process.env.NODE_ENV || 'development'; 7 | 8 | const DIST = 'dist'; 9 | const FILENAME = 'SendToClient'; 10 | let sites = require('./sites.json'); 11 | 12 | const bundleOptions = { 13 | extend: true, 14 | esModule: false, 15 | }; 16 | const postcssOptions = { 17 | ...require('@gera2ld/plaid/config/postcssrc'), 18 | inject: false, 19 | minimize: true, 20 | }; 21 | 22 | const coolreplace = () => { 23 | return { 24 | name: 'coolreplace', 25 | 26 | buildStart() { 27 | sites = require('./sites.json'); 28 | }, 29 | 30 | renderChunk(code, chunk) { 31 | return { 32 | code: code.replaceAll(/'sites\[(.*?)\]'/g, (match, site) => 33 | JSON.stringify(sites[site]) 34 | ), 35 | }; 36 | }, 37 | 38 | transform(code, id) { 39 | return { 40 | code: code.replaceAll(/'sites\[(.*?)\]'/g, (match, site) => 41 | JSON.stringify(sites[site]) 42 | ), 43 | }; 44 | }, 45 | }; 46 | }; 47 | const rollupConfig = [ 48 | { 49 | input: { 50 | input: 'src/index.js', 51 | plugins: [ 52 | ...getRollupPlugins({ 53 | esm: true, 54 | minimize: false, 55 | postcss: postcssOptions, 56 | }), 57 | userscript(path.resolve('src/meta.js'), (meta) => 58 | meta 59 | .replace('process.env.VERSION', pkg.version) 60 | .replace('process.env.AUTHOR', pkg.author) 61 | .replace( 62 | '// @match STC.sites', 63 | (() => { 64 | let result = ''; 65 | for (let type in sites) { 66 | for (let site of sites[type]) { 67 | result += `// @match *://*.${site}/*\n`; 68 | } 69 | } 70 | return result.replace(/\n$/, ''); 71 | })() 72 | ) 73 | ), 74 | coolreplace(), 75 | ], 76 | }, 77 | output: { 78 | format: 'iife', 79 | file: `${DIST}/${FILENAME}.user.js`, 80 | banner: 81 | env === 'development' 82 | ? `GM.registerMenuCommand('Built @ ${new Date()}', ()=>{});` 83 | : '', 84 | ...bundleOptions, 85 | }, 86 | }, 87 | ]; 88 | 89 | rollupConfig.forEach((item) => { 90 | item.output = { 91 | indent: false, 92 | // If set to false, circular dependencies and live bindings for external imports won't work 93 | externalLiveBindings: false, 94 | ...item.output, 95 | }; 96 | }); 97 | 98 | module.exports = rollupConfig.map(({ input, output }) => ({ 99 | ...input, 100 | output, 101 | })); 102 | -------------------------------------------------------------------------------- /sites.json: -------------------------------------------------------------------------------- 1 | { 2 | "Gazelle": [ 3 | "gazellegames.net", 4 | "animebytes.tv", 5 | "orpheus.network", 6 | "passthepopcorn.me", 7 | "greatposterwall.com", 8 | "redacted.ch", 9 | "jpopsuki.eu", 10 | "tv-vault.me", 11 | "sugoimusic.me", 12 | "ianon.app", 13 | "alpharatio.cc", 14 | "uhdbits.org", 15 | "morethantv.me", 16 | "empornium.is", 17 | "deepbassnine.com", 18 | "broadcasthe.net", 19 | "secret-cinema.pw" 20 | ], 21 | "BLU UNIT3D": ["blutopia.cc", "aither.cc"], 22 | "UNIT3D": ["desitorrents.tv", "jptv.club", "telly.wtf", "torrentseeds.org"], 23 | "TorrentLeech": ["torrentleech.org", "www.torrentleech.org"], 24 | "AnilistBytes": ["anilist.co"], 25 | "Karagarga": ["karagarga.in"], 26 | "F3NIX": ["beyond-hd.me"] 27 | } 28 | -------------------------------------------------------------------------------- /src/XFetch.js: -------------------------------------------------------------------------------- 1 | export const XFetch = { 2 | post: async (url, data, headers = {}) => { 3 | return new Promise((resolve, reject) => { 4 | GM.xmlHttpRequest({ 5 | method: 'POST', 6 | url, 7 | headers, 8 | data, 9 | onload: (res) => { 10 | resolve({ 11 | json: async () => JSON.parse(res.responseText), 12 | text: async () => res.responseText, 13 | headers: async () => 14 | Object.fromEntries( 15 | res.responseHeaders.split('\r\n').map((h) => h.split(': ')) 16 | ), 17 | raw: res, 18 | }); 19 | }, 20 | }); 21 | }); 22 | }, 23 | get: async (url) => { 24 | return new Promise((resolve, reject) => { 25 | GM.xmlHttpRequest({ 26 | method: 'GET', 27 | url, 28 | headers: { 29 | Accept: 'application/json', 30 | }, 31 | onload: (res) => { 32 | resolve({ 33 | json: async () => JSON.parse(res.responseText), 34 | text: async () => res.responseText, 35 | headers: async () => 36 | Object.fromEntries( 37 | res.responseHeaders.split('\r\n').map((h) => h.split(': ')) 38 | ), 39 | raw: res, 40 | }); 41 | }, 42 | }); 43 | }); 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /src/clientUtils.js: -------------------------------------------------------------------------------- 1 | import { XFetch } from './XFetch'; 2 | 3 | export const addTorrent = async ( 4 | torrentUrl, 5 | clientUrl, 6 | username, 7 | password, 8 | client, 9 | path, 10 | category 11 | ) => { 12 | let implementations = { 13 | qbit: async () => { 14 | XFetch.post( 15 | `${clientUrl}/api/v2/auth/login`, 16 | `username=${username}&password=${password}`, 17 | { 'content-type': 'application/x-www-form-urlencoded' } 18 | ); 19 | let tor_data = new FormData(); 20 | tor_data.append('urls', torrentUrl); 21 | if (path) { 22 | tor_data.append('savepath', path); 23 | } 24 | tor_data.append('category', category); 25 | XFetch.post(`${clientUrl}/api/v2/torrents/add`, tor_data); 26 | }, 27 | trans: async (session_id = null) => { 28 | let headers = { 29 | Authorization: `Basic ${btoa(`${username}:${password}`)}`, 30 | 'Content-Type': 'application/json', 31 | }; 32 | if (session_id) headers['X-Transmission-Session-Id'] = session_id; 33 | let res = await XFetch.post( 34 | `${clientUrl}/transmission/rpc`, 35 | JSON.stringify({ 36 | arguments: { filename: torrentUrl, 'download-dir': path }, 37 | method: 'torrent-add', 38 | }), 39 | headers 40 | ); 41 | if (res.raw.status === 409) { 42 | implementations.trans( 43 | (await res.headers())['X-Transmission-Session-Id'] 44 | ); 45 | } 46 | }, 47 | flood: async () => { 48 | // login 49 | XFetch.post( 50 | `${clientUrl}/api/auth/authenticate`, 51 | JSON.stringify({ password, username }), 52 | { 'content-type': 'application/json' } 53 | ); 54 | XFetch.post( 55 | `${clientUrl}/api/torrents/add-urls`, 56 | JSON.stringify({ urls: [torrentUrl], destination: path, start: true }), 57 | { 'content-type': 'application/json' } 58 | ); 59 | }, 60 | deluge: async () => { 61 | XFetch.post( 62 | `${clientUrl}/json`, 63 | JSON.stringify({ 64 | method: 'auth.login', 65 | params: [password], 66 | id: 0, 67 | }), 68 | { 'content-type': 'application/json' } 69 | ); 70 | let res = await XFetch.post( 71 | `${clientUrl}/json`, 72 | JSON.stringify({ 73 | method: 'web.download_torrent_from_url', 74 | params: [torrentUrl], 75 | id: 1, 76 | }), 77 | { 'content-type': 'application/json' } 78 | ); 79 | XFetch.post( 80 | `${clientUrl}/json`, 81 | JSON.stringify({ 82 | method: 'web.add_torrents', 83 | params: [ 84 | [ 85 | { 86 | path: (await res.json()).result, 87 | options: { 88 | add_paused: false, 89 | download_location: path, 90 | }, 91 | }, 92 | ], 93 | ], 94 | id: 2, 95 | }), 96 | { 'content-type': 'application/json' } 97 | ); 98 | }, 99 | rutorrent: async () => { 100 | // credit to humeur 101 | let headers = { 102 | Authorization: `Basic ${btoa(`${username}:${password}`)}`, 103 | }; 104 | const response = await fetch(torrentUrl); 105 | const data = await response.blob(); 106 | let form = new FormData(); 107 | form.append('torrent_file[]', data, 'sendtoclient.torrent'); 108 | 109 | form.append('torrents_start_stopped', 'true'); 110 | form.append('dir_edit', path); 111 | form.append('label', category); 112 | XFetch.post(`${clientUrl}/rutorrent/php/addtorrent.php?json=1`, form, headers); 113 | } 114 | }; 115 | 116 | await implementations[client](); 117 | }; 118 | 119 | export async function testClient(clientUrl, username, password, client) { 120 | let clients = { 121 | trans: async () => { 122 | let headers = { 123 | Authorization: `Basic ${btoa(`${username}:${password}`)}`, 124 | 'Content-Type': 'application/json', 125 | 'X-Transmission-Session-Id': null, 126 | }; 127 | let res = await XFetch.post( 128 | `${clientUrl}/transmission/rpc`, 129 | null, 130 | headers 131 | ); 132 | if (res.raw.status !== 401) { 133 | return true; 134 | } 135 | return false; 136 | }, 137 | qbit: async () => { 138 | let res = await XFetch.post( 139 | `${clientUrl}/api/v2/auth/login`, 140 | `username=${username}&password=${password}`, 141 | { 'content-type': 'application/x-www-form-urlencoded', cookie: 'SID=' } 142 | ); 143 | if ((await res.text()) === 'Ok.') { 144 | return true; 145 | } 146 | return false; 147 | }, 148 | deluge: async () => { 149 | let res = await XFetch.post( 150 | `${clientUrl}/json`, 151 | JSON.stringify({ 152 | method: 'auth.login', 153 | params: [password], 154 | id: 0, 155 | }), 156 | { 'content-type': 'application/json' } 157 | ); 158 | try { 159 | if ((await res.json()).result) { 160 | return true; 161 | } 162 | } catch (e) { 163 | return false; 164 | } 165 | return false; 166 | }, 167 | flood: async () => { 168 | let res = await XFetch.post( 169 | `${clientUrl}/api/auth/authenticate`, 170 | JSON.stringify({ password, username }), 171 | { 'content-type': 'application/json' } 172 | ); 173 | try { 174 | if ((await res.json()).success) return true; 175 | } catch (e) { 176 | return false; 177 | } 178 | return false; 179 | }, 180 | rutorrent: async () => { 181 | // credit to humeur 182 | let headers = { 183 | Authorization: `Basic ${btoa(`${username}:${password}`)}`, 184 | 'Content-Type': 'application/json' 185 | }; 186 | let res = await XFetch.post(`${clientUrl}/rutorrent/php/addtorrent.php?json=1`, null, headers); 187 | if (res.raw.status !== 401) { 188 | return true; 189 | } 190 | return false 191 | // credit to humeur; 192 | } 193 | }; 194 | let result = await clients[client](); 195 | return result; 196 | } 197 | // TODO: new implementation - there should be a class for each client implementating the needed methods 198 | export const getCategories = async (clientUrl, username, password) => { 199 | XFetch.post( 200 | `${clientUrl}/api/v2/auth/login`, 201 | `username=${username}&password=${password}`, 202 | { 'content-type': 'application/x-www-form-urlencoded' } 203 | ); 204 | let res = await XFetch.get(`${clientUrl}/api/v2/torrents/categories`); 205 | try { 206 | return Object.keys(await res.json()); 207 | } catch { 208 | return []; 209 | } 210 | }; 211 | 212 | export async function detectClient(url) { 213 | const res = await XFetch.get(url); 214 | const body = await res.text(); 215 | const headers = await res.headers(); 216 | if (headers.hasOwnProperty('WWW-Authenticate')) { 217 | const wwwAuthenticateHeader = headers['WWW-Authenticate']; 218 | if (wwwAuthenticateHeader.includes('"Transmission"')) return 'trans'; 219 | } 220 | if (body.includes('Deluge ')) return 'deluge'; 221 | if (body.includes('<title>Flood')) return 'flood'; 222 | if (body.includes('qBittorrent ')) return 'qbit'; 223 | if (body.includes('ruTorrent ')) return 'rutorrent'; 224 | return 'unknown'; 225 | } 226 | -------------------------------------------------------------------------------- /src/globalSettingsManager.js: -------------------------------------------------------------------------------- 1 | const ButtonTypes = { 2 | simple: 0, 3 | extended: 1, 4 | } 5 | 6 | export const globalSettingsManager = { 7 | settings: { 8 | button_type: ButtonTypes.simple, 9 | }, 10 | 11 | get button_type() { 12 | return this.settings.button_type; 13 | }, 14 | 15 | set button_type(val) { 16 | this.settings.button_type = val; 17 | this.save(); 18 | }, 19 | 20 | async load() { 21 | let settings = await GM.getValue('settings'); 22 | if (settings) { 23 | this.settings = JSON.parse(settings); 24 | } 25 | }, 26 | 27 | 28 | async save() { 29 | await GM.setValue('settings', JSON.stringify(this.settings)); 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { profileManager } from './profileManager'; 2 | import { Settings } from './settings'; 3 | import { createButtons } from './trackerUtils'; 4 | import { globalSettingsManager } from './globalSettingsManager.js'; 5 | 6 | GM.registerMenuCommand('Settings', () => { 7 | Settings(); 8 | }); 9 | const profileQuickSwitcher = () => { 10 | let id = GM.registerMenuCommand( 11 | `Selected Profile: ${profileManager.selectedProfile.name}`, 12 | () => {} 13 | ); 14 | window.addEventListener('profileChanged', () => { 15 | GM.unregisterMenuCommand(id); 16 | profileQuickSwitcher(); 17 | window.removeEventListener('profileChanged', () => {}); 18 | }); 19 | }; 20 | globalSettingsManager.load().then(()=> 21 | profileManager.load().then(() => { 22 | profileQuickSwitcher(); 23 | createButtons(); 24 | })); 25 | 26 | document.addEventListener('PTPAddReleasesFromOtherTrackersComplete', () => { 27 | console.log('Adding buttons for added releases'); 28 | profileQuickSwitcher(); 29 | createButtons(); 30 | }); -------------------------------------------------------------------------------- /src/meta.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name SendToClient 3 | // @namespace NotMareks Scripts 4 | // @description Painlessly send torrents to your bittorrent client. 5 | // @match STC.sites 6 | // @grant GM_addStyle 7 | // @grant GM.registerMenuCommand 8 | // @grant GM.unregisterMenuCommand 9 | // @grant GM.getValue 10 | // @grant GM.setValue 11 | // @grant GM.xmlHttpRequest 12 | // @version process.env.VERSION 13 | // @author process.env.AUTHOR 14 | // @require https://cdn.jsdelivr.net/combine/npm/@violentmonkey/dom@2,npm/@violentmonkey/ui@0.7 15 | // ==/UserScript== 16 | 17 | /** 18 | * @match STC.sites gets replaced with the sites from the sites.json file 19 | */ 20 | -------------------------------------------------------------------------------- /src/profileManager.js: -------------------------------------------------------------------------------- 1 | import { testClient, addTorrent, getCategories } from './clientUtils'; 2 | 3 | class Profile { 4 | constructor( 5 | id, 6 | name, 7 | host, 8 | username, 9 | password, 10 | client, 11 | saveLocation, 12 | category, 13 | linkedTo = [] 14 | ) { 15 | this.id = id; 16 | this.name = name; 17 | this.host = host; 18 | this.username = username; 19 | this.password = password; 20 | this.client = client; 21 | this.saveLocation = saveLocation; 22 | this.category = category; 23 | this.linkedTo = linkedTo; 24 | } 25 | async linkTo(site, replace = false) { 26 | let alreadyLinkedTo = profileManager.profiles.find((p) => 27 | p.linkedTo.includes(site) 28 | ); 29 | if (alreadyLinkedTo && !replace) { 30 | return alreadyLinkedTo.name; 31 | } else if (alreadyLinkedTo && replace) { 32 | alreadyLinkedTo.unlinkFrom(site); 33 | } 34 | if (this.linkedTo.includes(site)) return true; 35 | this.linkedTo.push(site); 36 | profileManager.save(); 37 | return true; 38 | } 39 | async unlinkFrom(site) { 40 | this.linkedTo = this.linkedTo.filter((s) => s !== site); 41 | profileManager.save(); 42 | } 43 | async getCategories() { 44 | if (this.client != 'qbit') return []; 45 | let res = await getCategories(this.host, this.username, this.password); 46 | console.log(res); 47 | return res; 48 | } 49 | async testConnection() { 50 | return await testClient( 51 | this.host, 52 | this.username, 53 | this.password, 54 | this.client 55 | ); 56 | } 57 | 58 | async addTorrent(torrent_uri) { 59 | return await addTorrent( 60 | torrent_uri, 61 | this.host, 62 | this.username, 63 | this.password, 64 | this.client, 65 | this.saveLocation, 66 | this.category 67 | ); 68 | } 69 | } 70 | 71 | export const profileManager = { 72 | profiles: [], 73 | selectedProfile: null, 74 | addProfile: function (profile) { 75 | this.profiles.push(profile); 76 | }, 77 | removeProfile: function (id) { 78 | this.profiles = this.profiles.find((p) => p.id === id); 79 | }, 80 | getProfile: function (id) { 81 | return ( 82 | this.profiles.find((p) => Number(p.id) === Number(id)) ?? 83 | new Profile(id, 'New Profile', '', '', '', 'none', '', '') 84 | ); 85 | }, 86 | getProfiles: function () { 87 | if (this.profiles.length === 0) this.load(); 88 | return this.profiles; 89 | }, 90 | setSelectedProfile: function (id) { 91 | this.selectedProfile = this.getProfile(id); 92 | window.dispatchEvent( 93 | new CustomEvent('profileChanged', { detail: this.selectedProfile }) 94 | ); 95 | }, 96 | setProfile: function (profile) { 97 | if (!this.profiles.includes(this.getProfile(profile.id))) { 98 | this.profiles.push(profile); 99 | } else { 100 | this.profiles = this.profiles.map((p) => { 101 | if (p.id === profile.id) { 102 | p = profile; 103 | } 104 | return p; 105 | }); 106 | } 107 | }, 108 | getNextId: function () { 109 | if (this.profiles.length === 0) return 0; 110 | return ( 111 | Number(this.profiles.sort((a, b) => Number(b.id) > Number(a.id))[0].id) + 112 | 1 113 | ); 114 | }, 115 | save: function () { 116 | GM.setValue('profiles', JSON.stringify(this.profiles)); 117 | GM.setValue('selectedProfile', this.selectedProfile.id); 118 | }, 119 | load: async function () { 120 | const profiles = await GM.getValue('profiles'); 121 | if (profiles) { 122 | this.profiles = JSON.parse(profiles).map( 123 | (p) => 124 | new Profile( 125 | p.id, 126 | p.name, 127 | p.host, 128 | p.username, 129 | p.password, 130 | p.client, 131 | p.saveLocation, 132 | p.category ?? '', 133 | p.linkedTo ?? [] 134 | ) 135 | ); 136 | } 137 | for (const profile of this.profiles) { 138 | for (const site of profile.linkedTo) { 139 | if (location.href.includes(site)) { 140 | this.selectedProfile = profile; 141 | return; 142 | } 143 | } 144 | } 145 | this.selectedProfile = 146 | this.getProfile(Number(await GM.getValue('selectedProfile')) ?? 0) ?? 147 | new Profile(0, 'New Profile', '', '', '', 'none', '', ''); 148 | }, 149 | }; 150 | -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | import styles, { stylesheet } from './style.module.css'; 2 | import { testClient, detectClient } from './clientUtils'; 3 | import { profileManager } from './profileManager'; 4 | import { globalSettingsManager } from './globalSettingsManager.js'; 5 | const clientSelectorOnChange = (e, shadow) => { 6 | if ( 7 | shadow.querySelector('#host').value === '' && 8 | e.target.value !== 'unknown' 9 | ) 10 | shadow.querySelector('#host').value = 11 | e.target.value === 'flood' 12 | ? document.location.href.replace(/\/overview|login\/$/, '') 13 | : document.location.href.replace(/\/$/, ''); 14 | shadow.querySelector('#category').hidden = e.target.value !== 'qbit'; 15 | shadow.querySelector("label[for='category']").hidden = 16 | e.target.value !== 'qbit'; 17 | if (e.target.value === 'qbit') { 18 | shadow.querySelector('#category>select').onload(); 19 | } 20 | shadow.querySelector("label[for='username']").hidden = 21 | e.target.value === 'deluge'; 22 | shadow.querySelector('#username').hidden = e.target.value === 'deluge'; 23 | }; 24 | 25 | function ClientSelector({ shadow }) { 26 | return ( 27 | <> 28 | <label for="client">Client:</label> 29 | 30 | <select 31 | id="client" 32 | name="client" 33 | onchange={(e) => clientSelectorOnChange(e, shadow)} 34 | > 35 | <option value="none" default> 36 | None 37 | </option> 38 | <option value="deluge">Deluge</option> 39 | <option value="flood">Flood</option> 40 | <option value="qbit">qBittorrent</option> 41 | <option value="trans">Transmission</option> 42 | <option value="rutorrent">ruTorrent</option> 43 | <option value="unknown" hidden> 44 | Not supported by auto detect 45 | </option> 46 | </select> 47 | </> 48 | ); 49 | } 50 | 51 | const profileOnSave = (e, shadow) => { 52 | let profile = profileManager.getProfile( 53 | shadow.querySelector('#profile').value 54 | ); 55 | profile.host = shadow.querySelector('#host').value; 56 | profile.username = shadow.querySelector('#username').value; 57 | profile.password = shadow.querySelector('#password').value; 58 | profile.client = shadow.querySelector('#client').value; 59 | profile.saveLocation = shadow.querySelector('#saveLocation').value; 60 | profile.name = shadow.querySelector('#profilename').value; 61 | profile.category = shadow.querySelector('#category>input').value; 62 | profileManager.setSelectedProfile(profile.id); 63 | profileManager.setProfile(profile); 64 | profileManager.save(); 65 | shadow.querySelector('#profile').innerHTML = null; 66 | shadow.querySelector('#profile').appendChild( 67 | VM.m( 68 | <> 69 | {profileManager.getProfiles().map((p) => { 70 | return ( 71 | <option 72 | selected={p.id === profileManager.selectedProfile.id} 73 | value={p.id} 74 | > 75 | {p.name} 76 | </option> 77 | ); 78 | })} 79 | <option value={profileManager.getNextId()}>New profile</option> 80 | </> 81 | ) 82 | ); 83 | }; 84 | 85 | const addSiteToProfile = async (hostname, shadow) => { 86 | let result = await profileManager.selectedProfile.linkTo(hostname); 87 | 88 | if ( 89 | result !== true && 90 | confirm( 91 | `This site is already linked to "${result}". Do you want to replace it?` 92 | ) 93 | ) 94 | profileManager.selectedProfile.linkTo(hostname, true); 95 | profileSelectHandler({ target: shadow.querySelector('#profile') }, shadow); 96 | }; 97 | function profileSelectHandler(e, shadow) { 98 | const profile = profileManager.getProfile(e.target.value); 99 | profileManager.setSelectedProfile(profile.id); 100 | shadow.querySelector('#host').value = profile.host; 101 | shadow.querySelector('#username').value = profile.username; 102 | shadow.querySelector('#password').value = profile.password; 103 | shadow.querySelector('#client').value = profile.client; 104 | shadow.querySelector('#saveLocation').value = profile.saveLocation; 105 | shadow.querySelector('#profilename').value = profile.name; 106 | shadow.querySelector('#linkToSite').innerHTML = null; 107 | shadow.querySelector('#linkToSite').appendChild( 108 | VM.m( 109 | <> 110 | {profileManager.selectedProfile.linkedTo.map((site) => ( 111 | <option value={site}>{site}</option> 112 | ))} 113 | {profileManager.selectedProfile.linkedTo.includes( 114 | location.hostname 115 | ) ? null : ( 116 | <option value={location.hostname}>Link to this site.</option> 117 | )} 118 | </> 119 | ) 120 | ); 121 | shadow 122 | .querySelector('select#client') 123 | .onchange({ target: shadow.querySelector('select#client') }); 124 | } 125 | 126 | function ProfileSelector({ shadow }) { 127 | return ( 128 | <> 129 | <label for="profile">Profile:</label> 130 | <select 131 | id="profile" 132 | name="profile" 133 | onchange={(e) => profileSelectHandler(e, shadow)} 134 | > 135 | {profileManager.getProfiles().map((p) => { 136 | return ( 137 | <option 138 | selected={p.id === profileManager.selectedProfile.id} 139 | value={p.id} 140 | > 141 | {p.name} 142 | </option> 143 | ); 144 | })} 145 | <option value={profileManager.getNextId()}>New profile</option> 146 | </select> 147 | </> 148 | ); 149 | } 150 | 151 | async function loadCategories(shadow) { 152 | let options = await profileManager.selectedProfile.getCategories().then((e) => 153 | e.map((cat) => ( 154 | <option 155 | value={cat} 156 | selected={profileManager.selectedProfile.category === cat} 157 | > 158 | {cat} 159 | </option> 160 | )) 161 | ); 162 | options.push( 163 | <option 164 | value="" 165 | default 166 | selected={profileManager.selectedProfile.category === ''} 167 | > 168 | Default 169 | </option> 170 | ); 171 | shadow.querySelector('#category>input').value = 172 | profileManager.selectedProfile.category; 173 | shadow.querySelector('select[name="category"]').innerHTML = null; 174 | shadow 175 | .querySelector('select[name="category"]') 176 | .appendChild(VM.m(<>{options}</>)); 177 | } 178 | function CategorySelector({ shadow, hidden }) { 179 | return ( 180 | <> 181 | <label for="category" hidden={hidden}> 182 | Category: 183 | </label> 184 | <div id="category" hidden={hidden} className={styles.select_input}> 185 | <select 186 | name="category" 187 | onload={() => loadCategories(shadow)} 188 | onchange={(e) => 189 | (shadow.querySelector('#category>input').value = e.target.value) 190 | } 191 | ></select> 192 | <input type="text" name="category" /> 193 | </div> 194 | </> 195 | ); 196 | } 197 | 198 | function LinkToSite({ shadow }) { 199 | return ( 200 | <> 201 | <label for="linkToSite">Linked to:</label> 202 | <select 203 | onchange={async (e) => { 204 | if (profileManager.selectedProfile.linkedTo.includes(e.target.value)) 205 | confirm('Do you want to unlink this site?') && 206 | profileManager.selectedProfile.unlinkFrom(e.target.value); 207 | else await addSiteToProfile(e.target.value, shadow); 208 | }} 209 | id="linkToSite" 210 | name="linkToSite" 211 | > 212 | {profileManager.selectedProfile.linkedTo.map((site) => ( 213 | <option value={site}>{site}</option> 214 | ))} 215 | {profileManager.selectedProfile.linkedTo.includes( 216 | location.hostname 217 | ) ? null : ( 218 | <option value={location.hostname}>Link to this site.</option> 219 | )} 220 | </select> 221 | </> 222 | ); 223 | } 224 | 225 | function SettingsElement({ panel }) { 226 | const shadow = panel.root; 227 | return ( 228 | <> 229 | <div className={styles.title}>SendToClient</div> 230 | <div> 231 | <div className={styles.settings}> 232 | <label for="btn-type" title="Toggles whatever you want to choose a profile while sending a torrent">Advanced button:</label> 233 | <input name="btn-type" 234 | type="checkbox" 235 | title="Change will be applied after a page reload" 236 | onchange={(e) => globalSettingsManager.button_type = Number(e.target.checked)} 237 | checked={globalSettingsManager.button_type ? true : false} /> 238 | </div> 239 | <form 240 | className={styles.settings} 241 | onsubmit={async (e) => { 242 | e.preventDefault(); 243 | profileOnSave(e, shadow); 244 | return false; 245 | }} 246 | > 247 | <ProfileSelector shadow={shadow} /> 248 | <LinkToSite shadow={shadow} /> 249 | <ClientSelector shadow={shadow} /> 250 | <label for="profilename">Profile name:</label> 251 | <input type="text" id="profilename" name="profilename" /> 252 | <label for="host">Host:</label> 253 | <input type="text" id="host" name="host" /> 254 | <label for="username">Username:</label> 255 | <input type="text" id="username" name="username" /> 256 | <label for="password">Password:</label> 257 | <input type="password" id="password" name="password" /> 258 | <CategorySelector 259 | hidden={profileManager.selectedProfile.client !== 'qbit'} 260 | shadow={shadow} 261 | /> 262 | 263 | <label for="saveLocation">Save location:</label> 264 | <input type="text" id="saveLocation" name="saveLocation" /> 265 | <button 266 | onclick={async (e) => { 267 | e.preventDefault(); 268 | shadow.querySelector('select#client').value = await detectClient( 269 | shadow.querySelector('#host').value 270 | ); 271 | shadow 272 | .querySelector('select#client') 273 | .onchange({ target: shadow.querySelector('select#client') }); 274 | return false; 275 | }} 276 | > 277 | Detect client 278 | </button> 279 | <button 280 | onclick={async (e) => { 281 | e.preventDefault(); 282 | shadow.querySelector('#res').innerText = (await testClient( 283 | shadow.querySelector('#host').value, 284 | shadow.querySelector('#username').value, 285 | shadow.querySelector('#password').value, 286 | shadow.querySelector('select#client').value 287 | )) 288 | ? 'Client seems to be working' 289 | : "Client doesn't seem to be working"; 290 | return false; 291 | }} 292 | > 293 | Test client 294 | </button> 295 | <input type="submit" value="Save" /> 296 | <button onclick={(e) => panel.hide()}>Close</button> 297 | </form> 298 | <p id="res" style="text-align: center;"></p> 299 | </div> 300 | </> 301 | ); 302 | } 303 | 304 | export const Settings = () => { 305 | const panel = VM.getPanel({ 306 | theme: 'dark', 307 | shadow: true, 308 | style: stylesheet, 309 | }); 310 | // give the panel access to itself :) 311 | panel.setContent(<SettingsElement panel={panel} />); 312 | panel.setMovable(false); 313 | panel.wrapper.children[0].classList.add(styles.wrapper); 314 | let original_show = panel.show; 315 | panel.show = () => { 316 | original_show.apply(panel); 317 | document.body.style.overflow = 'hidden'; 318 | }; 319 | 320 | let original_hide = panel.hide; 321 | panel.hide = () => { 322 | original_hide.apply(panel); 323 | document.body.style.overflow = 'auto'; 324 | }; 325 | panel.show(); 326 | profileSelectHandler( 327 | { target: { value: profileManager.selectedProfile.id } }, 328 | panel.root 329 | ); 330 | }; 331 | -------------------------------------------------------------------------------- /src/style.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | font-weight: 700; 3 | font-size: 20px; 4 | line-height: 24px; 5 | margin-bottom: 10px; 6 | text-align: center; 7 | } 8 | 9 | .desc { 10 | @apply text-green-600; 11 | } 12 | 13 | .settings { 14 | display: grid; 15 | grid-template-columns: 1fr 1fr; 16 | grid-row-gap: 1rem; 17 | grid-column-gap: 1rem; 18 | } 19 | 20 | :host { 21 | backdrop-filter: blur(5px); 22 | position: fixed; 23 | top: 0; 24 | left: 0; 25 | width: 100%; 26 | height: 100%; 27 | display: flex; 28 | justify-content: center; 29 | align-items: center; 30 | z-index: 99999999999; 31 | } 32 | 33 | .wrapper { 34 | border-radius: 10px; 35 | padding: 20px; 36 | } 37 | 38 | .select_input { 39 | background-color: white; 40 | border: solid grey 1px; 41 | height: 20px; 42 | position: relative; 43 | } 44 | 45 | .select_input > select { 46 | border: none; 47 | bottom: 0px; 48 | left: 0px; 49 | margin: 0; 50 | position: absolute; 51 | top: 0px; 52 | width: 100%; 53 | } 54 | 55 | .select_input > input { 56 | border: none; 57 | left: 0px; 58 | padding: 1px; 59 | position: absolute; 60 | top: 0px; 61 | width: calc(100% - 20px); 62 | } 63 | -------------------------------------------------------------------------------- /src/trackerUtils.js: -------------------------------------------------------------------------------- 1 | import { profileManager } from './profileManager'; 2 | import { globalSettingsManager } from './globalSettingsManager.js'; 3 | import styles, { stylesheet } from './style.module.css'; 4 | function ExtendeSTCProfile({ panel, profile, torrentUrl }) { 5 | return (<button style="display: block; padding: 5px; margin: 5px; cursor: pointer;" onclick={ 6 | (e)=>{ 7 | profile.addTorrent(torrentUrl); 8 | return panel.hide(); 9 | } 10 | }> 11 | {profile.name} 12 | </button> 13 | ); 14 | }; 15 | function ExtendedSTCElement({ panel, torrentUrl }) { 16 | let profiles = []; 17 | for (let profile of profileManager.profiles) { 18 | profiles.push(<ExtendeSTCProfile panel={panel} profile={profile} torrentUrl={torrentUrl}/>); 19 | } 20 | return (<div style="display: flex; flex-direction: column; align-items: center; justify-content:center;"> 21 | Choose which profile to send to 22 | {profiles} 23 | <button style="display: block; padding: 5px; margin: 5px; background-color: #fe0000; cursor: pointer;" onclick={() => panel.hide()}>Cancel</button> 24 | </div>); 25 | }; 26 | 27 | const ExtendedSTC = (torrentUrl) => { 28 | const panel = VM.getPanel({ 29 | theme: 'dark', 30 | shadow: true, 31 | style: stylesheet, 32 | }); 33 | // give the panel access to itself :) 34 | panel.setContent(<ExtendedSTCElement panel={panel} torrentUrl={torrentUrl} />); 35 | panel.setMovable(false); 36 | panel.wrapper.children[0].classList.add(styles.wrapper); 37 | let original_show = panel.show; 38 | panel.show = () => { 39 | original_show.apply(panel); 40 | document.body.style.overflow = 'hidden'; 41 | }; 42 | 43 | let original_hide = panel.hide; 44 | panel.hide = () => { 45 | original_hide.apply(panel); 46 | document.body.style.overflow = 'auto'; 47 | }; 48 | panel.show(); 49 | }; 50 | const XSTBTN = ({ torrentUrl, freeleech }) => { 51 | return ( 52 | <a title="Add to client - extended!" 53 | href="#" 54 | className="sendtoclient" 55 | onclick={async (e) => { 56 | if (freeleech) 57 | if (!confirm('After sending to client a feeleech token will be consumed!')) 58 | return; 59 | 60 | ExtendedSTC(torrentUrl); 61 | }} 62 | > 63 | X{freeleech ? "F" : ""}ST 64 | </a> 65 | ); 66 | } 67 | const STBTN = ({ torrentUrl }) => { 68 | return globalSettingsManager.button_type ? <XSTBTN freeleech={false} torrentUrl={torrentUrl}/> : ( 69 | <a 70 | title={`Add to ${profileManager.selectedProfile.name}.`} 71 | href="#" 72 | className="sendtoclient" 73 | onclick={async (e) => { 74 | e.preventDefault(); 75 | await profileManager.selectedProfile.addTorrent(torrentUrl); 76 | e.target.innerText = 'Added!'; 77 | e.target.onclick = null; 78 | }} 79 | > 80 | ST 81 | </a> 82 | ); 83 | }; 84 | const FSTBTN = ({ torrentUrl }) => { 85 | return globalSettingsManager.button_type ? <XSTBTN freeleech={true} torrentUrl={torrentUrl} /> : ( 86 | <a 87 | href="#" 88 | title={`Freeleechize and add to ${profileManager.selectedProfile.name}.`} 89 | className="sendtoclient" 90 | onclick={async (e) => { 91 | e.preventDefault(); 92 | if (!confirm('Are you sure you want to use a freeleech token here?')) 93 | return; 94 | await profileManager.selectedProfile.addTorrent(torrentUrl); 95 | e.target.innerText = 'Added!'; 96 | e.target.onclick = null; 97 | }} 98 | > 99 | FST 100 | </a> 101 | ); 102 | }; 103 | 104 | const handlers = [{ 105 | name: 'Gazelle', 106 | matches: ["gazellegames.net", "animebytes.tv", "orpheus.network", "passthepopcorn.me", "greatposterwall.com", "redacted.ch", "jpopsuki.eu", "tv-vault.me", "sugoimusic.me", "ianon.app", "alpharatio.cc", "uhdbits.org", "morethantv.me", "empornium.is", "deepbassnine.com", "broadcasthe.net", "secret-cinema.pw"], 107 | run: async () => { 108 | const links = Array.from(document.querySelectorAll('a')).filter(a => 109 | a.innerText.trim() === 'DL' || 110 | a.title === 'Download Torrent' || 111 | a.classList.contains('link_1') 112 | ); 113 | 114 | for (const a of links) { 115 | let parent = a.closest('.basic-movie-list__torrent__action'); 116 | if (!parent) { 117 | parent = a.parentElement; 118 | } 119 | let torrentUrl = a.href; 120 | let buttons = Array.from(parent.childNodes).filter(e => e.nodeName !== '#text'); 121 | let fl = Array.from(parent.querySelectorAll('a')).find(a => a.innerText === 'FL'); 122 | let fst = fl ? VM.h(VM.Fragment, null, "\xA0|\xA0", VM.h(FSTBTN, { 123 | torrentUrl: fl.href 124 | })) : null; 125 | 126 | parent.innerHTML = ''; // Use '' instead of null to avoid issues 127 | parent.appendChild(VM.m(VM.h(VM.Fragment, null, "[\xA0", buttons.map(e => VM.h(VM.Fragment, null, e, " | ")), VM.h(STBTN, { 128 | torrentUrl: torrentUrl 129 | }), fst, "\xA0]"))); 130 | } 131 | 132 | window.addEventListener('profileChanged', () => { 133 | document.querySelectorAll('a.sendtoclient').forEach(e => { 134 | if (e.title.includes('Freeleechize')) { 135 | e.title = `Freeleechize and add to ${profileManager.selectedProfile.name}.`; 136 | } else { 137 | e.title = `Add to ${profileManager.selectedProfile.name}.`; 138 | } 139 | }); 140 | }); 141 | } 142 | }, 143 | { 144 | name: 'BLU UNIT3D', 145 | matches: 'sites[BLU UNIT3D]', 146 | run: async () => { 147 | let rid = await fetch( 148 | Array.from(document.querySelectorAll('ul>li>a')).find( 149 | (e) => e.innerText === 'My Profile' 150 | ).href + '/rsskey/edit' 151 | ) 152 | .then((e) => e.text()) 153 | .then( 154 | (e) => 155 | e 156 | .replaceAll(/\s/g, '') 157 | .match( 158 | /name="current_rsskey"readonlytype="text"value="(.*?)">/ 159 | )[1] 160 | ); 161 | handlers.find((h) => h.name === 'UNIT3D').run(rid); 162 | }, 163 | }, 164 | { 165 | name: 'F3NIX', 166 | matches: 'sites[F3NIX]', 167 | run: async (rid = null) => { 168 | if (!rid) { 169 | rid = await fetch(location.origin + '/settings/change_rid') 170 | .then((e) => e.text()) 171 | .then( 172 | (e) => 173 | e.match( 174 | /class="beta-form-main" name="null" value="(.*?)" disabled>/ 175 | )[1] 176 | ); 177 | } 178 | const appendButton = () => { 179 | Array.from( 180 | document.querySelectorAll('a[title="Download Torrent"]') 181 | ).forEach((a) => { 182 | let parent = a.parentElement; 183 | let torrentUrl = `${a.href.replace( 184 | '/download/', 185 | '/torrent/download/' 186 | )}.${rid}`; 187 | parent.appendChild( 188 | VM.m( 189 | <> 190 | {' '} 191 | <STBTN torrentUrl={torrentUrl} /> 192 | </> 193 | ) 194 | ); 195 | }); 196 | }; 197 | appendButton(); 198 | let oldPushState = unsafeWindow.history.pushState; 199 | unsafeWindow.history.pushState = function () { 200 | console.log( 201 | '[SendToClient] Detected a soft navigation to ${unsafeWindow.location.href}' 202 | ); 203 | appendButton(); 204 | return oldPushState.apply(this, arguments); 205 | }; 206 | }, 207 | }, 208 | { 209 | name: 'UNIT3D', 210 | matches: 'sites[UNIT3D]', 211 | run: async (rid = null) => { 212 | if (!rid) { 213 | rid = await fetch( 214 | Array.from(document.querySelectorAll('ul>li>a')).find((e) => 215 | e.innerText.includes('My Profile') 216 | ).href + '/settings/security' 217 | ) 218 | .then((e) => e.text()) 219 | .then((e) => e.match(/ current_rid">(.*?)</)[1]); 220 | } 221 | const appendButton = () => { 222 | Array.from(document.querySelectorAll('a[title="Download"]')) 223 | .concat( 224 | Array.from( 225 | document.querySelectorAll( 226 | 'button[title="Download"], button[data-original-title="Download"]' 227 | ) 228 | ).map((e) => e.parentElement) 229 | ) 230 | .forEach((a) => { 231 | let parent = a.parentElement; 232 | let torrentUrl = 233 | a.href.replace('/torrents/', '/torrent/') + `.${rid}`; 234 | parent.appendChild(VM.m(<STBTN torrentUrl={torrentUrl} />)); 235 | }); 236 | }; 237 | appendButton(); 238 | console.log( 239 | '[SendToClient] Bypassing CSP so we can listen for soft navigations.' 240 | ); 241 | 242 | document.addEventListener('popstate', () => { 243 | console.log( 244 | '[SendToClient] Detected a soft navigation to ' + 245 | unsafeWindow.location.href 246 | ); 247 | appendButton(); 248 | }); 249 | // listen for a CSP violation so that we can grab the nonces 250 | document.addEventListener('securitypolicyviolation', (e) => { 251 | const nonce = e.originalPolicy.match(/nonce-(.*?)'/)[1]; 252 | let actualScript = VM.m( 253 | <script nonce={nonce}> 254 | {`console.log('[SendToClient] Adding a navigation listener.'); 255 | (() => { 256 | let oldPushState = history.pushState; 257 | history.pushState = function pushState() { 258 | let ret = oldPushState.apply(this, arguments); 259 | document.dispatchEvent(new Event('popstate')); 260 | return ret; 261 | }; 262 | })();`} 263 | </script> 264 | ); 265 | document.head.appendChild(actualScript).remove(); 266 | }); 267 | // trigger a CSP violation 268 | document.head 269 | .appendChild( 270 | VM.m(<script nonce="nonce-123">window.csp = "csp :(";</script>) 271 | ) 272 | .remove(); 273 | }, 274 | }, 275 | { 276 | name: 'Karagarga', 277 | matches: 'sites[Karagarga]', 278 | run: async () => { 279 | if (unsafeWindow.location.href.includes('details.php')) { 280 | let dl_btn = document.querySelector('a.index'); 281 | let torrent_uri = dl_btn.href; 282 | return dl_btn.insertAdjacentElement( 283 | 'afterend', 284 | VM.m( 285 | <span> 286 |   <STBTN torrentUrl={torrent_uri} /> 287 | </span> 288 | ) 289 | ); 290 | } 291 | document.querySelectorAll("img[alt='Download']").forEach((e) => { 292 | let parent = e.parentElement; 293 | let torrent_uri = e.parentElement.href; 294 | let container = parent.parentElement; 295 | let st = VM.m(<STBTN torrentUrl={torrent_uri} />); 296 | container.appendChild(st); 297 | }); 298 | }, 299 | }, 300 | { 301 | name: 'TorrentLeech', 302 | matches: 'sites[TorrentLeech]', 303 | run: async () => { 304 | const username = document 305 | .querySelector('span.link') 306 | .getAttribute('onclick') 307 | .match('/profile/(.*?)/view')[1]; 308 | let rid = await fetch(`/profile/${username}/edit`) 309 | .then((e) => e.text()) 310 | .then( 311 | (e) => 312 | e.replaceAll(/\s/g, '').match(/rss.torrentleech.org\/(.*?)\</)[1] 313 | ); 314 | document.head.appendChild( 315 | VM.m(<style>{`td.td-quick-download { display: flex; }`}</style>) 316 | ); 317 | for (const a of document.querySelectorAll('a.download')) { 318 | let torrent_uri = a.href.match(/\/download\/(\d*?)\/(.*?)$/); 319 | torrent_uri = `https://torrentleech.org/rss/download/${torrent_uri[1]}/${rid}/${torrent_uri[2]}`; 320 | a.parentElement.appendChild(VM.m(<STBTN torrentUrl={torrent_uri} />)); 321 | } 322 | }, 323 | }, 324 | { 325 | name: 'AnilistBytes', 326 | matches: 'sites[AnilistBytes]', 327 | run: async () => { 328 | unsafeWindow._addTo = async (torrentUrl) => 329 | profileManager.selectedProfile.addTorrent(torrentUrl); 330 | }, 331 | }, 332 | ]; 333 | 334 | export const createButtons = async () => { 335 | document.querySelectorAll('.sendtoclient').forEach(button => button.remove()); 336 | 337 | for (const handler of handlers) { 338 | const regex = handler.matches.join('|'); 339 | if (unsafeWindow.location.href.match(regex)) { 340 | handler.run(); 341 | console.log(`%c[SendToClient] Using engine {${handler.name}}`, 'color: #42adf5; font-weight: bold; font-size: 1.5em;'); 342 | return handler.name; 343 | } 344 | } 345 | }; 346 | -------------------------------------------------------------------------------- /src/types/css.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.css' { 2 | /** 3 | * Generated CSS for CSS modules 4 | */ 5 | export const stylesheet: string; 6 | /** 7 | * Exported classes 8 | */ 9 | const classMap: { 10 | [key: string]: string; 11 | }; 12 | export default classMap; 13 | } 14 | 15 | declare module '*.css' { 16 | /** 17 | * Generated CSS 18 | */ 19 | const css: string; 20 | export default css; 21 | } 22 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import * as dom from '@violentmonkey/dom'; 2 | import * as ui from '@violentmonkey/ui'; 3 | 4 | declare global { 5 | const VM: typeof dom & typeof ui; 6 | 7 | namespace JSX { 8 | /** 9 | * JSX.Element can be different based on pragma in babel config: 10 | * - VNode - when jsxFactory is VM.h 11 | * - DomNode - when jsxFactory is VM.hm 12 | */ 13 | type Element = import('@gera2ld/jsx-dom').VNode; 14 | } 15 | } 16 | --------------------------------------------------------------------------------