Repository: searxng/searxng

Issue:
Disable .torrent files/magnet links for private trackers available through Jackett
**Is your feature request related to a problem? Please describe.**
Private trackers are very strict about distribution of .torrent files and will ban users that distribute them. Most only have restrictions about the .torrent files themselves, and not the content, since they are linked with the user account.
Allowing all the users of a search engine to download .torrent files/magnet links from private trackers added via Jackett is something that would get the admin of the searXNG instance banned very quickly from those private trackers.

**Describe the solution you'd like**
Disable .torrent/magnet links for private trackers. Only allow the user to visit the torrent link and download the files from their own account.

**Describe alternatives you've considered**
I don't think there are any other alternatives to fix this issue.



Discussion:

Resolving patch:
diff --git a/searx/engines/torznab.py b/searx/engines/torznab.py
--- a/searx/engines/torznab.py
+++ b/searx/engines/torznab.py
@@ -1,21 +1,83 @@
 # SPDX-License-Identifier: AGPL-3.0-or-later
 # lint: pylint
-"""Torznab WebAPI
+""".. _torznab engine:
 
-A engine that implements the `torznab WebAPI`_.
+==============
+Torznab WebAPI
+==============
 
-.. _torznab WebAPI: https://torznab.github.io/spec-1.3-draft/torznab
+.. contents:: Contents
+   :depth: 2
+   :local:
+   :backlinks: entry
+
+Torznab_ is an API specification that provides a standardized way to query
+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
+===============
 
 """
+from __future__ import annotations
+from typing import TYPE_CHECKING
 
+from typing import List, Dict, Any
 from datetime import datetime
 from urllib.parse import quote
-from lxml import etree
+from lxml import etree  # type: ignore
 
 from searx.exceptions import SearxEngineAPIException
 
-# about
-about = {
+if TYPE_CHECKING:
+    import httpx
+    import logging
+
+    logger: logging.Logger
+
+# engine settings
+about: Dict[str, Any] = {
     "website": None,
     "wikidata_id": None,
     "official_api_documentation": "https://torznab.github.io/spec-1.3-draft",
@@ -23,27 +85,30 @@
     "require_api_key": False,
     "results": 'XML',
 }
-
-categories = ['files']
-paging = False
-time_range_support = False
+categories: List[str] = ['files']
+paging: bool = False
+time_range_support: bool = False
 
 # defined in settings.yml
 # example (Jackett): "http://localhost:9117/api/v2.0/indexers/all/results/torznab"
-base_url = ''
-api_key = ''
+base_url: str = ''
+api_key: str = ''
 # https://newznab.readthedocs.io/en/latest/misc/api/#predefined-categories
-torznab_categories = []
+torznab_categories: List[str] = []
+show_torrent_files: bool = False
+show_magnet_links: bool = True
 
 
 def init(engine_settings=None):  # pylint: disable=unused-argument
+    """Initialize the engine."""
     if len(base_url) < 1:
         raise ValueError('missing torznab base_url')
 
 
-def request(query, params):
+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}'
 
-    search_url = base_url + '?t=search&q={search_query}'
     if len(api_key) > 0:
         search_url += '&apikey={api_key}'
     if len(torznab_categories) > 0:
@@ -56,88 +121,135 @@ def request(query, params):
     return params
 
 
-def response(resp):
+def response(resp: httpx.Response) -> List[Dict[str, Any]]:
+    """Parse the XML response and return a list of results."""
     results = []
-
     search_results = etree.XML(resp.content)
 
-    # handle errors
-    # https://newznab.readthedocs.io/en/latest/misc/api/#newznab-error-codes
+    # handle errors:  https://newznab.readthedocs.io/en/latest/misc/api/#newznab-error-codes
     if search_results.tag == "error":
         raise SearxEngineAPIException(search_results.get("description"))
 
-    for item in search_results[0].iterfind('item'):
-        result = {'template': 'torrent.html'}
+    channel: etree.Element = search_results[0]
 
-        enclosure = item.find('enclosure')
+    item: etree.Element
+    for item in channel.iterfind('item'):
+        result: Dict[str, Any] = build_result(item)
+        results.append(result)
 
-        result["filesize"] = int(enclosure.get('length'))
+    return results
 
-        link = get_property(item, 'link')
-        guid = get_property(item, 'guid')
-        comments = get_property(item, 'comments')
 
-        # define url
-        result["url"] = enclosure.get('url')
-        if comments is not None and comments.startswith('http'):
-            result["url"] = comments
-        elif guid is not None and guid.startswith('http'):
-            result["url"] = guid
+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
 
-        # define torrent file url
-        result["torrentfile"] = None
-        if enclosure.get('url').startswith("http"):
-            result["torrentfile"] = enclosure.get('url')
-        elif link is not None and link.startswith('http'):
-            result["torrentfile"] = link
 
-        # define magnet link
-        result["magnetlink"] = get_torznab_attr(item, 'magneturl')
-        if result["magnetlink"] is None:
-            if enclosure.get('url').startswith("magnet"):
-                result["magnetlink"] = enclosure.get('url')
-            elif link is not None and link.startswith('magnet'):
-                result["magnetlink"] = link
+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
 
-        result["title"] = get_property(item, 'title')
-        result["files"] = get_property(item, 'files')
 
-        result["publishedDate"] = None
+def _map_published_date(pubDate: str | None) -> datetime | None:
+    if pubDate is not None:
         try:
-            result["publishedDate"] = datetime.strptime(get_property(item, 'pubDate'), '%a, %d %b %Y %H:%M:%S %z')
+            return datetime.strptime(pubDate, '%a, %d %b %Y %H:%M:%S %z')
         except (ValueError, TypeError) as e:
             logger.debug("ignore exception (publishedDate): %s", e)
+    return None
 
-        result["seed"] = get_torznab_attr(item, 'seeders')
-
-        # define leech
-        result["leech"] = get_torznab_attr(item, 'leechers')
-        if result["leech"] is None and result["seed"] is not None:
-            peers = get_torznab_attr(item, 'peers')
-            if peers is not None:
-                result["leech"] = int(peers) - int(result["seed"])
 
-        results.append(result)
+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
 
-    return results
 
+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
 
-def get_property(item, property_name):
-    property_element = item.find(property_name)
 
+def get_attribute(item: etree.Element, property_name: str) -> str | None:
+    """Get attribute from item."""
+    property_element: etree.Element | None = item.find(property_name)
     if property_element is not None:
         return property_element.text
-
     return None
 
 
-def get_torznab_attr(item, attr_name):
-    element = item.find(
-        './/torznab:attr[@name="{attr_name}"]'.format(attr_name=attr_name),
+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),
         {'torznab': 'http://torznab.com/schemas/2015/feed'},
     )
-
     if element is not None:
         return element.get("value")
-
     return None


Test patch: