| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | # SPDX-License-Identifier: AGPL-3.0-or-later | 
					
						
							| 
									
										
										
										
											2021-10-07 09:35:50 +02:00
										 |  |  | # lint: pylint | 
					
						
							| 
									
										
										
										
											2023-06-30 18:07:02 +02:00
										 |  |  | """Torznab_ is an API specification that provides a standardized way to query
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | torrent site for content. It is used by a number of torrent applications, | 
					
						
							|  |  |  | including Prowlarr_ and Jackett_. | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | Using this engine together with Prowlarr_ or Jackett_ allows you to search | 
					
						
							|  |  |  | a huge number of torrent sites which are not directly supported. | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | Configuration | 
					
						
							|  |  |  | ============= | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | The engine has the following settings: | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | ``base_url``: | 
					
						
							|  |  |  |   Torznab endpoint URL. | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | ``api_key``: | 
					
						
							|  |  |  |   The API key to use for authentication. | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | ``torznab_categories``: | 
					
						
							|  |  |  |   The categories to use for searching. This is a list of category IDs.  See | 
					
						
							|  |  |  |   Prowlarr-categories_ or Jackett-categories_ for more information. | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | ``show_torrent_files``: | 
					
						
							|  |  |  |   Whether to show the torrent file in the search results.  Be carful as using | 
					
						
							|  |  |  |   this with Prowlarr_ or Jackett_ leaks the API key.  This should be used only | 
					
						
							|  |  |  |   if you are querying a Torznab endpoint without authentication or if the | 
					
						
							|  |  |  |   instance is private.  Be aware that private trackers may ban you if you share | 
					
						
							|  |  |  |   the torrent file.  Defaults to ``false``. | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | ``show_magnet_links``: | 
					
						
							|  |  |  |   Whether to show the magnet link in the search results.  Be aware that private | 
					
						
							|  |  |  |   trackers may ban you if you share the magnet link.  Defaults to ``true``. | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | .. _Torznab: | 
					
						
							|  |  |  |    https://torznab.github.io/spec-1.3-draft/index.html | 
					
						
							|  |  |  | .. _Prowlarr: | 
					
						
							|  |  |  |    https://github.com/Prowlarr/Prowlarr | 
					
						
							|  |  |  | .. _Jackett: | 
					
						
							|  |  |  |    https://github.com/Jackett/Jackett | 
					
						
							|  |  |  | .. _Prowlarr-categories: | 
					
						
							|  |  |  |    https://wiki.servarr.com/en/prowlarr/cardigann-yml-definition#categories | 
					
						
							|  |  |  | .. _Jackett-categories: | 
					
						
							|  |  |  |    https://github.com/Jackett/Jackett/wiki/Jackett-Categories | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | Implementations | 
					
						
							|  |  |  | =============== | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | """
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | from __future__ import annotations | 
					
						
							|  |  |  | from typing import TYPE_CHECKING | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | from typing import List, Dict, Any | 
					
						
							| 
									
										
										
										
											2021-10-07 09:35:50 +02:00
										 |  |  | from datetime import datetime | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | from urllib.parse import quote | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | from lxml import etree  # type: ignore | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | from searx.exceptions import SearxEngineAPIException | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | if TYPE_CHECKING: | 
					
						
							|  |  |  |     import httpx | 
					
						
							|  |  |  |     import logging | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     logger: logging.Logger | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | # engine settings | 
					
						
							|  |  |  | about: Dict[str, Any] = { | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  |     "website": None, | 
					
						
							|  |  |  |     "wikidata_id": None, | 
					
						
							| 
									
										
										
										
											2021-10-07 09:35:50 +02:00
										 |  |  |     "official_api_documentation": "https://torznab.github.io/spec-1.3-draft", | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  |     "use_official_api": True, | 
					
						
							|  |  |  |     "require_api_key": False, | 
					
						
							|  |  |  |     "results": 'XML', | 
					
						
							|  |  |  | } | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | categories: List[str] = ['files'] | 
					
						
							|  |  |  | paging: bool = False | 
					
						
							|  |  |  | time_range_support: bool = False | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | # defined in settings.yml | 
					
						
							|  |  |  | # example (Jackett): "http://localhost:9117/api/v2.0/indexers/all/results/torznab" | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | base_url: str = '' | 
					
						
							|  |  |  | api_key: str = '' | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | # https://newznab.readthedocs.io/en/latest/misc/api/#predefined-categories | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | torznab_categories: List[str] = [] | 
					
						
							|  |  |  | show_torrent_files: bool = False | 
					
						
							|  |  |  | show_magnet_links: bool = True | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-12-27 09:26:22 +01:00
										 |  |  | 
 | 
					
						
							|  |  |  | def init(engine_settings=None):  # pylint: disable=unused-argument | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  |     """Initialize the engine.""" | 
					
						
							| 
									
										
										
										
											2021-10-07 11:38:27 +02:00
										 |  |  |     if len(base_url) < 1: | 
					
						
							|  |  |  |         raise ValueError('missing torznab base_url') | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-12-27 09:26:22 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | def request(query: str, params: Dict[str, Any]) -> Dict[str, Any]: | 
					
						
							|  |  |  |     """Build the request params.""" | 
					
						
							|  |  |  |     search_url: str = base_url + '?t=search&q={search_query}' | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  |     if len(api_key) > 0: | 
					
						
							|  |  |  |         search_url += '&apikey={api_key}' | 
					
						
							|  |  |  |     if len(torznab_categories) > 0: | 
					
						
							|  |  |  |         search_url += '&cat={torznab_categories}' | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     params['url'] = search_url.format( | 
					
						
							| 
									
										
										
										
											2021-12-27 09:26:22 +01:00
										 |  |  |         search_query=quote(query), api_key=api_key, torznab_categories=",".join([str(x) for x in torznab_categories]) | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  |     ) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     return params | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-12-27 09:26:22 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | def response(resp: httpx.Response) -> List[Dict[str, Any]]: | 
					
						
							|  |  |  |     """Parse the XML response and return a list of results.""" | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  |     results = [] | 
					
						
							|  |  |  |     search_results = etree.XML(resp.content) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  |     # handle errors:  https://newznab.readthedocs.io/en/latest/misc/api/#newznab-error-codes | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  |     if search_results.tag == "error": | 
					
						
							|  |  |  |         raise SearxEngineAPIException(search_results.get("description")) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  |     channel: etree.Element = search_results[0] | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  |     item: etree.Element | 
					
						
							|  |  |  |     for item in channel.iterfind('item'): | 
					
						
							|  |  |  |         result: Dict[str, Any] = build_result(item) | 
					
						
							|  |  |  |         results.append(result) | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  |     return results | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | def build_result(item: etree.Element) -> Dict[str, Any]: | 
					
						
							|  |  |  |     """Build a result from a XML item.""" | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     # extract attributes from XML | 
					
						
							|  |  |  |     # see https://torznab.github.io/spec-1.3-draft/torznab/Specification-v1.3.html#predefined-attributes | 
					
						
							|  |  |  |     enclosure: etree.Element | None = item.find('enclosure') | 
					
						
							|  |  |  |     enclosure_url: str | None = None | 
					
						
							|  |  |  |     if enclosure is not None: | 
					
						
							|  |  |  |         enclosure_url = enclosure.get('url') | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     size = get_attribute(item, 'size') | 
					
						
							|  |  |  |     if not size and enclosure: | 
					
						
							|  |  |  |         size = enclosure.get('length') | 
					
						
							|  |  |  |     if size: | 
					
						
							|  |  |  |         size = int(size) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     guid = get_attribute(item, 'guid') | 
					
						
							|  |  |  |     comments = get_attribute(item, 'comments') | 
					
						
							|  |  |  |     pubDate = get_attribute(item, 'pubDate') | 
					
						
							|  |  |  |     seeders = get_torznab_attribute(item, 'seeders') | 
					
						
							|  |  |  |     leechers = get_torznab_attribute(item, 'leechers') | 
					
						
							|  |  |  |     peers = get_torznab_attribute(item, 'peers') | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     # map attributes to searx result | 
					
						
							|  |  |  |     result: Dict[str, Any] = { | 
					
						
							|  |  |  |         'template': 'torrent.html', | 
					
						
							|  |  |  |         'title': get_attribute(item, 'title'), | 
					
						
							|  |  |  |         'filesize': size, | 
					
						
							|  |  |  |         'files': get_attribute(item, 'files'), | 
					
						
							|  |  |  |         'seed': seeders, | 
					
						
							|  |  |  |         'leech': _map_leechers(leechers, seeders, peers), | 
					
						
							|  |  |  |         'url': _map_result_url(guid, comments), | 
					
						
							|  |  |  |         'publishedDate': _map_published_date(pubDate), | 
					
						
							|  |  |  |         'torrentfile': None, | 
					
						
							|  |  |  |         'magnetlink': None, | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     link = get_attribute(item, 'link') | 
					
						
							|  |  |  |     if show_torrent_files: | 
					
						
							|  |  |  |         result['torrentfile'] = _map_torrent_file(link, enclosure_url) | 
					
						
							|  |  |  |     if show_magnet_links: | 
					
						
							|  |  |  |         magneturl = get_torznab_attribute(item, 'magneturl') | 
					
						
							|  |  |  |         result['magnetlink'] = _map_magnet_link(magneturl, guid, enclosure_url, link) | 
					
						
							|  |  |  |     return result | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def _map_result_url(guid: str | None, comments: str | None) -> str | None: | 
					
						
							|  |  |  |     if guid and guid.startswith('http'): | 
					
						
							|  |  |  |         return guid | 
					
						
							|  |  |  |     if comments and comments.startswith('http'): | 
					
						
							|  |  |  |         return comments | 
					
						
							|  |  |  |     return None | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | def _map_leechers(leechers: str | None, seeders: str | None, peers: str | None) -> str | None: | 
					
						
							|  |  |  |     if leechers: | 
					
						
							|  |  |  |         return leechers | 
					
						
							|  |  |  |     if seeders and peers: | 
					
						
							|  |  |  |         return str(int(peers) - int(seeders)) | 
					
						
							|  |  |  |     return None | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | def _map_published_date(pubDate: str | None) -> datetime | None: | 
					
						
							|  |  |  |     if pubDate is not None: | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  |         try: | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  |             return datetime.strptime(pubDate, '%a, %d %b %Y %H:%M:%S %z') | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  |         except (ValueError, TypeError) as e: | 
					
						
							| 
									
										
										
										
											2021-10-07 09:35:50 +02:00
										 |  |  |             logger.debug("ignore exception (publishedDate): %s", e) | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  |     return None | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | def _map_torrent_file(link: str | None, enclosure_url: str | None) -> str | None: | 
					
						
							|  |  |  |     if link and link.startswith('http'): | 
					
						
							|  |  |  |         return link | 
					
						
							|  |  |  |     if enclosure_url and enclosure_url.startswith('http'): | 
					
						
							|  |  |  |         return enclosure_url | 
					
						
							|  |  |  |     return None | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | def _map_magnet_link( | 
					
						
							|  |  |  |     magneturl: str | None, | 
					
						
							|  |  |  |     guid: str | None, | 
					
						
							|  |  |  |     enclosure_url: str | None, | 
					
						
							|  |  |  |     link: str | None, | 
					
						
							|  |  |  | ) -> str | None: | 
					
						
							|  |  |  |     if magneturl and magneturl.startswith('magnet'): | 
					
						
							|  |  |  |         return magneturl | 
					
						
							|  |  |  |     if guid and guid.startswith('magnet'): | 
					
						
							|  |  |  |         return guid | 
					
						
							|  |  |  |     if enclosure_url and enclosure_url.startswith('magnet'): | 
					
						
							|  |  |  |         return enclosure_url | 
					
						
							|  |  |  |     if link and link.startswith('magnet'): | 
					
						
							|  |  |  |         return link | 
					
						
							|  |  |  |     return None | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | def get_attribute(item: etree.Element, property_name: str) -> str | None: | 
					
						
							|  |  |  |     """Get attribute from item.""" | 
					
						
							|  |  |  |     property_element: etree.Element | None = item.find(property_name) | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  |     if property_element is not None: | 
					
						
							|  |  |  |         return property_element.text | 
					
						
							|  |  |  |     return None | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-24 18:58:27 +02:00
										 |  |  | def get_torznab_attribute(item: etree.Element, attribute_name: str) -> str | None: | 
					
						
							|  |  |  |     """Get torznab special attribute from item.""" | 
					
						
							|  |  |  |     element: etree.Element | None = item.find( | 
					
						
							|  |  |  |         './/torznab:attr[@name="{attribute_name}"]'.format(attribute_name=attribute_name), | 
					
						
							| 
									
										
										
										
											2021-12-27 09:26:22 +01:00
										 |  |  |         {'torznab': 'http://torznab.com/schemas/2015/feed'}, | 
					
						
							| 
									
										
										
										
											2021-10-07 02:34:42 +02:00
										 |  |  |     ) | 
					
						
							|  |  |  |     if element is not None: | 
					
						
							|  |  |  |         return element.get("value") | 
					
						
							|  |  |  |     return None |