# This file is part of torf. # # torf is free software: you can redistribute it and/or modify it under the # terms of the GNU General Public License as published by the Free Software # Foundation, either version 3 of the License, or (at your option) any later # version. # # torf is distributed in the hope that it will be useful, but WITHOUT ANY # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR # A PARTICULAR PURPOSE. See the GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with torf. If not, see <https://www.gnu.org/licenses/>. import base64 import binascii import io import re import time import urllib from collections import abc, defaultdict from . import _errors as error from . import _utils as utils class Magnet(): """ BTIH Magnet URI :param str xt: eXact Topic: Info hash (Base 16 or 32) :param str dn: Display Name: Name of the torrent :param int xl: eXact Length: Size in bytes :param list tr: TRacker: Iterable of tracker URLs :param str xs: eXact Source: Torrent file URL :param str as\_: Acceptable Source: Fallback torrent file URL :param list ws: WebSeeds: Iterable of webseed URLs (see BEP19) :param list kt: Keyword Topic: List of search keywords All keyword arguments that start with ``x_`` go into the :attr:`x` dictionary with the part after the underscore as the key. They appear as "x.<name>" in the rendered URI. References: | https://www.bittorrent.org/beps/bep_0009.html | https://en.wikipedia.org/wiki/Magnet_URL | http://magnet-uri.sourceforge.net/magnet-draft-overview.txt | https://wiki.theory.org/index.php/BitTorrent_Magnet-URI_Webseeding | http://shareaza.sourceforge.net/mediawiki/index.php/Magnet_URI_scheme """ _INFOHASH_REGEX = re.compile(r'^[0-9a-f]{40}|[a-z2-7]{32}$', flags=re.IGNORECASE) _XT_REGEX = re.compile(r'^urn:btih:([0-9a-f]{40}|[a-z2-7]{32})$', flags=re.IGNORECASE) def __init__(self, xt, *, dn=None, xl=None, tr=None, xs=None, as_=None, ws=None, kt=None, **kwargs): self._tr = utils.MonitoredList(type=utils.URL) self._ws = utils.MonitoredList(type=utils.URL) self.xt = xt self.dn = dn self.xl = xl self.tr = tr self.xs = xs self.as_ = as_ self.ws = ws self.kt = kt self._x = defaultdict(lambda: None) for key in tuple(kwargs): if key.startswith('x_'): self._x[key[2:]] = kwargs.pop(key) if kwargs: key, value = next(iter(kwargs.items())) raise TypeError(f'Unrecognized argument: {key}={value!r}') @property def dn(self): """Display Name: Name of the torrent or ``None``""" return self._dn @dn.setter def dn(self, value): self._dn = str(value).replace('\n', ' ') if value is not None else None @property def xt(self): """ eXact Topic: URN containing the info hash as base 16 or base 32 Example: urn:btih:3bb9561e35b06175bb6d2c2330578dc83846cc5d For convenience, this property may be set to the info hash without the ``urn:btih`` part. :raises MagnetError: if set to an invalid value """ return f'urn:btih:{self._infohash}' @xt.setter def xt(self, value): value = str(value) if self._INFOHASH_REGEX.match(value): self._infohash = value else: match = self._XT_REGEX.match(value) if match: self._infohash = match.group(1) if not hasattr(self, '_infohash'): raise error.MagnetError(value, 'Invalid exact topic ("xt")') @property def infohash(self): """ Info hash as base 16 or base 32 :raises MagnetError: if set to an invalid value """ return self._infohash @infohash.setter def infohash(self, value): value = str(value) match = self._INFOHASH_REGEX.match(value) if match: self._infohash = value else: raise error.MagnetError(value, 'Invalid info hash') @property def xl(self): """ eXact Length: Size in bytes or ``None`` :raises MagnetError: if set to an invalid value """ return self._xl @xl.setter def xl(self, value): if value is not None: try: value = int(value) except ValueError: raise error.MagnetError(value, 'Invalid exact length ("xl")') else: if value < 1: raise error.MagnetError(value, 'Must be 1 or larger') else: self._xl = value else: self._xl = None @property def tr(self): """ TRackers: List of tracker URLs, single tracker URL or ``None`` :raises URLError: if any of the URLs is invalid """ return self._tr @tr.setter def tr(self, value): if value is None: self._tr.clear() elif isinstance(value, str): self._tr.replace((value,)) else: self._tr.replace(value) @property def xs(self): """ eXact Source: Torrent file URL or ``None`` :raises URLError: if the URL is invalid """ return self._xs @xs.setter def xs(self, value): self._xs = utils.URL(value) if value is not None else None @property def as_(self): """ Acceptable Source: Fallback torrent file URL or ``None`` (The trailing underscore is needed because "as" is a keyword in Python.) :raises URLError: if the URL is invalid """ return self._as @as_.setter def as_(self, value): self._as = utils.URL(value) if value is not None else None @property def ws(self): """ WebSeeds: List of webseed URLs, single webseed URL or ``None`` See BEP19. :raises URLError: if any of the URLs is invalid """ return self._ws @ws.setter def ws(self, value): if value is None: self._ws.clear() elif isinstance(value, str): self._ws.replace((value,)) else: self._ws.replace(value) @property def kt(self): """Keyword Topic: List of search keywords or ``None``""" return self._kt @kt.setter def kt(self, value): if value is None: self._kt = [] elif isinstance(value, str): self._kt = [value] elif isinstance(value, abc.Iterable): self._kt = [str(v) for v in value] if value is not None else None else: raise error.MagnetError(value, 'Invalid keyword topic ("kt")') @property def x(self): """ Mapping of custom keys to their values For example, "x.pe" (a peer address) would be accessed as ``magnet.x['pe']``. """ return self._x def torrent(self): """:class:`Torrent` instance""" # Prevent circular import issues from ._torrent import Torrent torrent = Torrent() torrent.name = self.dn if self.tr: torrent.trackers = self.tr if self.ws: torrent.webseeds = self.ws if self.xl: torrent._metainfo['info']['length'] = self.xl if hasattr(self, '_info'): torrent.metainfo['info'] = self._info elif len(self.infohash) == 40: torrent._infohash = self.infohash else: # Convert base 32 to base 16 (SHA1) torrent._infohash = base64.b16encode( base64.b32decode(self.infohash)).decode('utf-8').lower() return torrent def get_info(self, validate=True, timeout=60, callback=None): """ Download the torrent's "info" section Try the following sources in this order: :attr:`xs`, :attr:`as`, :attr:`tr` :meth:`torrent` can only return a complete torrent if this method is called first. :param validate: Whether to ensure the downloaded "info" section is valid :param timeout: Give up after this many seconds :type timeout: int, float :param callback callable: Callable that is called with a :class:`TorfError` instance if a source is specified but fails :return: ``True`` if the "info" section was successfully downloaded, ``False`` otherwise """ def success(): return hasattr(self, '_info') torrent_urls = [] if self.xs: torrent_urls.append(self.xs) if self.as_: torrent_urls.append(self.as_) torrent_urls.extend((url.rstrip('/') + '.torrent' for url in self.ws)) # I couldn't find any documentation for the "/file?info_hash=..." GET request, but # it seems to work for HTTP trackers. # https://stackoverflow.com/a/1019588 for url in self.tr: if url.scheme in ('http', 'https'): infohash_enc = urllib.parse.quote_from_bytes(binascii.unhexlify(self.infohash)) torrent_urls.append(f'{url.scheme}://{url.netloc}/file?info_hash={infohash_enc}') start = time.monotonic() for url in torrent_urls: to = timeout - (time.monotonic() - start) try: torrent = utils.download(url, timeout=to) except error.ConnectionError as e: if callback: callback(e) else: self._set_info_from_torrent(torrent, validate, callback) if success() or to <= 0: break return success() def _set_info_from_torrent(self, torrent_data, validate=True, callback=False): """Extract "info" section from `torrent_data` for :meth:`torrent`""" # Prevent circular import issues from ._torrent import Torrent stream = io.BytesIO(torrent_data) try: torrent = Torrent.read_stream(stream, validate=validate) except error.TorfError as e: if callback: callback(e) else: if validate and self.infohash != torrent.infohash: raise error.MetainfoError(f'Mismatching info hashes: {self.infohash} != {torrent.infohash}') elif torrent.metainfo['info']: self._info = torrent.metainfo['info'] _KNOWN_PARAMETERS = ('xt', 'dn', 'xl', 'tr', 'xs', 'as', 'ws', 'kt') @classmethod def from_string(cls, uri): """ Create :class:`Magnet` URI from string :raises URLError: if `uri` contains an invalid URL (e.g. :attr:`tr`) :raises MagnetError: if `uri` is not a valid magnet URI """ info = urllib.parse.urlparse(uri.strip(), scheme='magnet', allow_fragments=False) if not info.scheme == 'magnet': raise error.MagnetError(uri, 'Not a magnet URI') else: query = urllib.parse.parse_qs(info.query) # Check for unknown parameters for key in query: if key not in cls._KNOWN_PARAMETERS and not key.startswith('x_'): raise error.MagnetError(uri, f'{key}: Unknown parameter') if 'xt' not in query: raise error.MagnetError(uri, 'Missing exact topic ("xt")') elif len(query['xt']) > 1: raise error.MagnetError(uri, 'Multiple exact topics ("xt")') else: self = cls(xt=query['xt'][0]) # Parameters that accept only one value for param,attr,name,parse in (('dn', 'dn', 'display name', lambda v: v), ('xl', 'xl', 'exact length', lambda v: v), ('xs', 'xs', 'exact source', lambda v: v), ('as', 'as_', 'acceptable source', lambda v: v), ('kt', 'kt', 'keyword topic', lambda v: v.split())): if param in query: if len(query[param]) > 1: raise error.MagnetError(uri, f'Multiple {name}s ("{param}")') else: setattr(self, attr, parse(query[param][0])) # Parameters that accept multiple values for param,name in (('tr', 'tracker'), ('ws', 'webseed')): if param in query: setattr(self, param, query[param]) return self def __str__(self): uri = [f'magnet:?xt={self.xt}'] for key in ('dn', 'xl', 'xs', 'as_'): value = getattr(self, f'{key}') if value is not None: if isinstance(value, str): uri.append(f'{key}={utils.urlquote(value)}') else: uri.append(f'{key}={value}') if self.kt: uri.append(f'kt={"+".join(utils.urlquote(k) for k in self.kt)}') for key in ('tr', 'ws'): seq = getattr(self, f'{key}') if seq is not None: for item in seq: uri.append(f'{key}={utils.urlquote(item)}') for key,value in self._x.items(): uri.append(f'x.{key}={utils.urlquote(value)}') return '&'.join(uri) def __repr__(self): clsname = type(self).__name__ kwargs = {} for param in self._KNOWN_PARAMETERS: if param == 'as': param = 'as_' value = getattr(self, param) if value: kwargs[param] = value for k,v in self.x.items(): kwargs[f'x_{k}'] = v kwargs_str = ', '.join(f'{k}={repr(v)}' for k,v in kwargs.items()) return f'{clsname}({kwargs_str})'