├── README.md ├── helpers.py ├── novaprinter.py ├── prowlarr-logo-lq.png └── prowlarr.py /README.md: -------------------------------------------------------------------------------- 1 | ### THIS REPO IS NO LONGER MAINTAINED. 2 | ### For a supported plugin please use Jackett. 3 | 4 | 5 | 6 | 7 | 8 | **qBittorrent** comes with a few search plugins. Although these are enough for most users, if you wish to add more search engines, you can download **Prowlarr** configure the **prowlarr qBittorrent plugin** (essentially, set the API key). 9 | 10 | **[prowlarr](https://github.com/prowlarr/prowlarr)** is a server program that provides support for more than 400 torrent sites (public and private). You may download the plugin at [this address](https://raw.githubusercontent.com/swannie-eire/prowlarr-qbittorrent-plugin/main/prowlarr.py). 11 | 12 | ### Disable the prowlarr plugin 13 | By default, the prowlarr plugin is enabled in qBittorrent. If you want to disable it, follow these steps: 14 | 1. In the `Search tab`, click the `Search plugins...` button (bottom-right) 15 | 2. Right click on the `prowlarr` plugin 16 | 3. Uncheck the `Enabled` checkbox 17 | 4. Close the modal window 18 | 19 | ### Configuration file 20 | The prowlarr plugin uses an external configuration file. This allows to update the plugin without losing the settings. 21 | 22 | The file `prowlarr.json` should be located in the qBittorrent search engines folder: 23 | * Windows: `%localappdata%\qBittorrent\nova3\engines\` 24 | * Linux: `~/.local/share/data/qBittorrent/nova3/engines/`, or `~/.local/share/qBittorrent/nova3/engines/`, or `~/.var/app/org.qbittorrent.qBittorrent/data/qBittorrent/nova3/engines` if using Flatpak 25 | * OS X: `~/Library/Application Support/qBittorrent/nova3/engines/` 26 | 27 | Note: If the file doesn't exist, you can create it by copying the following JSON: 28 | 29 | ```json 30 | { 31 | "api_key": "YOUR_API_KEY_HERE", 32 | "tracker_first": false, 33 | "url": "http://127.0.0.1:9696" 34 | } 35 | ``` 36 | 37 | Note 3: Remember to [start prowlarr](https://github.com/prowlarr/prowlarr) first. :) 38 | 39 | Note 4: If running qBittorrent headless and using the web page on a remote server, prowlarr needs to be configured to allow remote calls and its IP. Eg: 40 | 41 | ```json 42 | $ cat config/data/qBittorrent/nova3/engines/prowlarr.json 43 | { 44 | "api_key": "YOUR_API_KEY_HERE", 45 | "tracker_first": false, 46 | "url": "http://yourserverip:9696" 47 | } 48 | ``` 49 | 50 | ### Configuration properties 51 | | Property | Default value | Description | 52 | |---|---|---| 53 | | api_key | YOUR_API_KEY_HERE | prowlarr API Key (you can find it int Settings > General > Security) | 54 | | tracker_first | false | (false/true) add tracker name to the beginning of search result | 55 | | url | http://127.0.0.1:9696 | prowlarr URL (without the end slash) | 56 | 57 | API Key in prowlarr web UI: 58 | 59 | ![](https://i.imgur.com/ePMq68M.png) 60 | 61 | ### Screenshot 62 | ![](https://i.imgur.com/a6WPJC8.png) 63 | -------------------------------------------------------------------------------- /helpers.py: -------------------------------------------------------------------------------- 1 | #VERSION: 1.43 2 | 3 | # Author: 4 | # Christophe DUMEZ (chris@qbittorrent.org) 5 | 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # * Redistributions of source code must retain the above copyright notice, 10 | # this list of conditions and the following disclaimer. 11 | # * Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in the 13 | # documentation and/or other materials provided with the distribution. 14 | # * Neither the name of the author nor the names of its contributors may be 15 | # used to endorse or promote products derived from this software without 16 | # specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 22 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 24 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 26 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 27 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 28 | # POSSIBILITY OF SUCH DAMAGE. 29 | 30 | import gzip 31 | import html.entities 32 | import io 33 | import os 34 | import re 35 | import socket 36 | import socks 37 | import tempfile 38 | import urllib.error 39 | import urllib.parse 40 | import urllib.request 41 | 42 | # Some sites blocks default python User-agent 43 | user_agent = 'Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0' 44 | headers = {'User-Agent': user_agent} 45 | # SOCKS5 Proxy support 46 | if "sock_proxy" in os.environ and len(os.environ["sock_proxy"].strip()) > 0: 47 | proxy_str = os.environ["sock_proxy"].strip() 48 | m = re.match(r"^(?:(?P[^:]+):(?P[^@]+)@)?(?P[^:]+):(?P\w+)$", 49 | proxy_str) 50 | if m is not None: 51 | socks.setdefaultproxy(socks.PROXY_TYPE_SOCKS5, m.group('host'), 52 | int(m.group('port')), True, m.group('username'), m.group('password')) 53 | socket.socket = socks.socksocket 54 | 55 | 56 | def htmlentitydecode(s): 57 | # First convert alpha entities (such as é) 58 | # (Inspired from http://mail.python.org/pipermail/python-list/2007-June/443813.html) 59 | def entity2char(m): 60 | entity = m.group(1) 61 | if entity in html.entities.name2codepoint: 62 | return chr(html.entities.name2codepoint[entity]) 63 | return " " # Unknown entity: We replace with a space. 64 | t = re.sub('&(%s);' % '|'.join(html.entities.name2codepoint), entity2char, s) 65 | 66 | # Then convert numerical entities (such as é) 67 | t = re.sub(r'&#(\d+);', lambda x: chr(int(x.group(1))), t) 68 | 69 | # Then convert hexa entities (such as é) 70 | return re.sub(r'&#x(\w+);', lambda x: chr(int(x.group(1), 16)), t) 71 | 72 | 73 | def retrieve_url(url): 74 | """ Return the content of the url page as a string """ 75 | req = urllib.request.Request(url, headers=headers) 76 | try: 77 | response = urllib.request.urlopen(req) 78 | except urllib.error.URLError as errno: 79 | print(" ".join(("Connection error:", str(errno.reason)))) 80 | return "" 81 | dat = response.read() 82 | # Check if it is gzipped 83 | if dat[:2] == b'\x1f\x8b': 84 | # Data is gzip encoded, decode it 85 | compressedstream = io.BytesIO(dat) 86 | gzipper = gzip.GzipFile(fileobj=compressedstream) 87 | extracted_data = gzipper.read() 88 | dat = extracted_data 89 | info = response.info() 90 | charset = 'utf-8' 91 | try: 92 | ignore, charset = info['Content-Type'].split('charset=') 93 | except Exception: 94 | pass 95 | dat = dat.decode(charset, 'replace') 96 | dat = htmlentitydecode(dat) 97 | # return dat.encode('utf-8', 'replace') 98 | return dat 99 | 100 | 101 | def download_file(url, referer=None): 102 | """ Download file at url and write it to a file, return the path to the file and the url """ 103 | file, path = tempfile.mkstemp() 104 | file = os.fdopen(file, "wb") 105 | # Download url 106 | req = urllib.request.Request(url, headers=headers) 107 | if referer is not None: 108 | req.add_header('referer', referer) 109 | response = urllib.request.urlopen(req) 110 | dat = response.read() 111 | # Check if it is gzipped 112 | if dat[:2] == b'\x1f\x8b': 113 | # Data is gzip encoded, decode it 114 | compressedstream = io.BytesIO(dat) 115 | gzipper = gzip.GzipFile(fileobj=compressedstream) 116 | extracted_data = gzipper.read() 117 | dat = extracted_data 118 | 119 | # Write it to a file 120 | file.write(dat) 121 | file.close() 122 | # return file path 123 | return (path + " " + url) -------------------------------------------------------------------------------- /novaprinter.py: -------------------------------------------------------------------------------- 1 | #VERSION: 1.46 2 | 3 | # Redistribution and use in source and binary forms, with or without 4 | # modification, are permitted provided that the following conditions are met: 5 | # 6 | # * Redistributions of source code must retain the above copyright notice, 7 | # this list of conditions and the following disclaimer. 8 | # * Redistributions in binary form must reproduce the above copyright 9 | # notice, this list of conditions and the following disclaimer in the 10 | # documentation and/or other materials provided with the distribution. 11 | # * Neither the name of the author nor the names of its contributors may be 12 | # used to endorse or promote products derived from this software without 13 | # specific prior written permission. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 18 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 19 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 20 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 21 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 22 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 23 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 25 | # POSSIBILITY OF SUCH DAMAGE. 26 | 27 | 28 | def prettyPrinter(dictionary): 29 | dictionary['size'] = anySizeToBytes(dictionary['size']) 30 | outtext = "|".join((dictionary["link"], dictionary["name"].replace("|", " "), 31 | str(dictionary["size"]), str(dictionary["seeds"]), 32 | str(dictionary["leech"]), dictionary["engine_url"])) 33 | if 'desc_link' in dictionary: 34 | outtext = "|".join((outtext, dictionary["desc_link"])) 35 | 36 | # fd 1 is stdout 37 | with open(1, 'w', encoding='utf-8', closefd=False) as utf8stdout: 38 | print(outtext, file=utf8stdout) 39 | 40 | 41 | def anySizeToBytes(size_string): 42 | """ 43 | Convert a string like '1 KB' to '1024' (bytes) 44 | """ 45 | # separate integer from unit 46 | try: 47 | size, unit = size_string.split() 48 | except: 49 | try: 50 | size = size_string.strip() 51 | unit = ''.join([c for c in size if c.isalpha()]) 52 | if len(unit) > 0: 53 | size = size[:-len(unit)] 54 | except: 55 | return -1 56 | if len(size) == 0: 57 | return -1 58 | size = float(size) 59 | if len(unit) == 0: 60 | return int(size) 61 | short_unit = unit.upper()[0] 62 | 63 | # convert 64 | units_dict = {'T': 40, 'G': 30, 'M': 20, 'K': 10} 65 | if short_unit in units_dict: 66 | size = size * 2**units_dict[short_unit] 67 | return int(size) -------------------------------------------------------------------------------- /prowlarr-logo-lq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swannie-eire/prowlarr-qbittorrent-plugin/0d1c0a3db570da31a13a866915c87d41c5cc7bfc/prowlarr-logo-lq.png -------------------------------------------------------------------------------- /prowlarr.py: -------------------------------------------------------------------------------- 1 | # VERSION: 1.0 2 | # prowlarr.py 3 | # AUTHORS: swannie-eire (https://github.com/swannie-eire) 4 | # CONTRIBUTORS: 5 | # Diego de las Heras (ngosang@hotmail.es) 6 | # ukharley 7 | # hannsen (github.com/hannsen) 8 | 9 | import json 10 | import os 11 | from urllib.parse import urlencode, unquote 12 | from urllib import request as urllib_request 13 | from http.cookiejar import CookieJar 14 | 15 | from novaprinter import prettyPrinter 16 | from helpers import download_file 17 | 18 | 19 | ############################################################################### 20 | # load configuration from file 21 | CONFIG_FILE = 'prowlarr.json' 22 | CONFIG_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), CONFIG_FILE) 23 | CONFIG_DATA = { 24 | 'api_key': 'YOUR_API_KEY_HERE', # prowlarr api 25 | 'tracker_first': False, # (False/True) add tracker name to beginning of search result 26 | 'url': 'http://127.0.0.1:9696', # prowlarr url 27 | } 28 | 29 | 30 | def load_configuration(): 31 | global CONFIG_PATH, CONFIG_DATA 32 | try: 33 | # try to load user data from file 34 | with open(CONFIG_PATH) as f: 35 | CONFIG_DATA = json.load(f) 36 | except ValueError: 37 | # if file exists but it's malformed we load add a flag 38 | CONFIG_DATA['malformed'] = True 39 | except Exception: 40 | # if file doesn't exist, we create it 41 | with open(CONFIG_PATH, 'w') as f: 42 | f.write(json.dumps(CONFIG_DATA, indent=4, sort_keys=True)) 43 | 44 | # do some checks 45 | if any(item not in CONFIG_DATA for item in ['api_key', 'tracker_first', 'url']): 46 | CONFIG_DATA['malformed'] = True 47 | 48 | 49 | load_configuration() 50 | ############################################################################### 51 | 52 | 53 | class prowlarr(object): 54 | name = 'prowlarr' 55 | url = CONFIG_DATA['url'] if CONFIG_DATA['url'][-1] != '/' else CONFIG_DATA['url'][:-1] 56 | api_key = CONFIG_DATA['api_key'] 57 | supported_categories = { 58 | 'all': None, 59 | 'anime': 5070, 60 | 'books': 8000, 61 | 'games': "1000&categories=4000", 62 | 'movies': 2000, 63 | 'music': 3000, 64 | 'software': 4000, 65 | 'tv': 5000, 66 | } 67 | 68 | 69 | def download_torrent(self, download_url): 70 | # fix for some indexers with magnet link inside .torrent file 71 | if download_url.startswith('magnet:?'): 72 | print(download_url + " " + download_url) 73 | response = self.get_response(download_url) 74 | if response is not None and response.startswith('magnet:?'): 75 | print(response + " " + download_url) 76 | else: 77 | print(download_file(download_url)) 78 | 79 | 80 | def search(self, what, cat='all'): 81 | what = unquote(what) 82 | category = self.supported_categories[cat.lower()] 83 | 84 | # check for malformed configuration 85 | if 'malformed' in CONFIG_DATA: 86 | self.handle_error("malformed configuration file", what) 87 | return 88 | 89 | # check api_key 90 | if self.api_key == "YOUR_API_KEY_HERE": 91 | self.handle_error("api key error", what) 92 | return 93 | 94 | if category is not None: 95 | prowlarr_url = self.url + "/api/v1/" + 'search?query=' + what.replace(' ', '+') + '&apikey=' + self.api_key + '&indexerIds=' + "-2" + "&categories=" + str(category) 96 | else: 97 | prowlarr_url = self.url + "/api/v1/" + 'search?query=' + what.replace(' ', '+') + '&apikey=' + self.api_key + '&indexerIds=' + "-2" 98 | 99 | response = self.get_response(prowlarr_url) 100 | if response is None: 101 | self.handle_error("connection error", what) 102 | return 103 | 104 | x = json.loads(response) 105 | 106 | # process search results 107 | for result in x: 108 | res = {} 109 | 110 | title = result.get('title') 111 | tracker = result.get('indexer') 112 | if CONFIG_DATA['tracker_first']: 113 | res['name'] = '[%s] %s' % (tracker, title) 114 | else: 115 | res['name'] = '%s [%s]' % (title, tracker) 116 | 117 | if 'downloadUrl' in result: 118 | res['link'] = str(result.get('downloadUrl')) 119 | elif 'magnetUrl' in result: 120 | res['link'] = str(result.get('magnetUrl')) 121 | else: 122 | res['link'] = "no link to downlaod" 123 | 124 | res['size'] = str(result.get('size')) 125 | res['seeds'] = result.get('seeders') 126 | res['seeds'] = -1 if res['seeds'] is None else res['seeds'] 127 | res['leech'] = result.get('leechers') 128 | res['leech'] = -1 if res['leech'] is None else res['leech'] 129 | res['desc_link'] = result.get('infoUrl') 130 | 131 | if res['desc_link'] is None: 132 | res['desc_link'] = str(result.get('guid')) 133 | res['desc_link'] = '' if res['desc_link'] is None else str(res['desc_link']) 134 | 135 | # note: engine_url can't be changed, torrent download stops working 136 | res['engine_url'] = self.url 137 | 138 | prettyPrinter(self.escape_pipe(res)) 139 | 140 | 141 | def generate_xpath(self, tag): 142 | return './{http://torznab.com/schemas/2015/feed}attr[@name="%s"]' % tag 143 | 144 | # Safety measure until it's fixed in prettyPrinter 145 | def escape_pipe(self, dictionary): 146 | for key in dictionary.keys(): 147 | if isinstance(dictionary[key], str): 148 | dictionary[key] = dictionary[key].replace('|', '%7C') 149 | return dictionary 150 | 151 | 152 | def get_response(self, query): 153 | response = None 154 | try: 155 | # we can't use helpers.retrieve_url because of redirects 156 | # we need the cookie processor to handle redirects 157 | opener = urllib_request.build_opener(urllib_request.HTTPCookieProcessor(CookieJar())) 158 | response = opener.open(query).read().decode('utf-8') 159 | except urllib_request.HTTPError as e: 160 | # if the page returns a magnet redirect, used in download_torrent 161 | if e.code == 302: 162 | response = e.url 163 | except Exception: 164 | pass 165 | return response 166 | 167 | 168 | def handle_error(self, error_msg, what): 169 | # we need to print the search text to be displayed in qBittorrent when 170 | # 'Torrent names only' is enabled 171 | prettyPrinter({ 172 | 'seeds': -1, 173 | 'size': -1, 174 | 'leech': -1, 175 | 'engine_url': self.url, 176 | 'link': self.url, 177 | 'desc_link': 'https://github.com/test', # noqa 178 | 'name': "Prowlarr: %s! Right-click this row and select 'Open description page' to open help. Configuration file: '%s' Search: '%s'" % (error_msg, CONFIG_PATH, what) # noqa 179 | }) 180 | 181 | 182 | if __name__ == "__main__": 183 | prowlarr_se = prowlarr() 184 | prowlarr_se.search("ubuntu") 185 | --------------------------------------------------------------------------------