+
diff --git a/platypush/plugins/torrent/__init__.py b/platypush/plugins/torrent/__init__.py
index edb0ec67a..5799cae90 100644
--- a/platypush/plugins/torrent/__init__.py
+++ b/platypush/plugins/torrent/__init__.py
@@ -1,11 +1,10 @@
+import inspect
import os
import pathlib
-import queue
import random
import threading
import time
-from urllib.parse import quote_plus
-from typing import Iterable, List, Optional, Union
+from typing import Dict, Iterable, Optional, Union
import requests
@@ -24,6 +23,9 @@ from platypush.message.event.torrent import (
)
from platypush.utils import get_default_downloads_dir
+from . import _search as search_module
+from ._search import TorrentSearchProvider
+
class TorrentPlugin(Plugin):
"""
@@ -38,75 +40,164 @@ class TorrentPlugin(Plugin):
default_torrent_ports = (6881, 6891)
torrent_state = {}
transfers = {}
- default_popcorn_base_url = 'https://shows.cf'
def __init__(
self,
download_dir: Optional[str] = None,
torrent_ports: Iterable[int] = default_torrent_ports,
- popcorn_base_url: str = default_popcorn_base_url,
+ search_providers: Optional[
+ Union[Dict[str, dict], Iterable[TorrentSearchProvider]]
+ ] = None,
**kwargs,
):
"""
:param download_dir: Directory where the videos/torrents will be
downloaded (default: ``~/Downloads``).
:param torrent_ports: Torrent ports to listen on (default: 6881 and 6891)
- :param popcorn_base_url: Custom base URL to use for the PopcornTime API.
- """
+ :param search_providers: List of search providers to use. Each provider
+ has its own supported configuration and needs to be an instance of
+ :class:`TorrentSearchProvider`. Currently supported providers:
+ * :class:`platypush.plugins.torrent._search.PopcornTimeSearchProvider`
+ * :class:`platypush.plugins.torrent._search.TorrentCsvSearchProvider`
+
+ Configuration example:
+
+ .. code-block:: yaml
+
+ torrent:
+ # ...
+
+ search_providers:
+ torrent_csv:
+ # Default: True
+ # enabled: true
+ # Base URL of the torrent-csv API.
+ # See https://git.torrents-csv.com/heretic/torrents-csv-server
+ # for how to run your own torrent-csv API server.
+ api_url: https://torrents-csv.com/service
+ # Alternatively, you can also use a local checkout of the
+ # torrent.csv file. Clone
+ # https://git.torrents-csv.com/heretic/torrents-csv-data
+ # and provide the path to the torrent.csv file here.
+ # csv_file: /path/to/torrent.csv
+ popcorn_time:
+ # Default: False
+ # enabled: false
+ # Required: PopcornTime API base URL.
+ # See https://github.com/popcorn-time-ru/popcorn-ru for
+ # how to run your own PopcornTime API server.
+ api_url: https://popcorntime.app
+
+ """
super().__init__(**kwargs)
self.torrent_ports = torrent_ports
self.download_dir = os.path.abspath(
os.path.expanduser(download_dir or get_default_downloads_dir())
)
+ self._search_providers = self._load_search_providers(search_providers)
self._sessions = {}
self._lt_session = None
- self.popcorn_base_url = popcorn_base_url
- self.torrent_base_urls = {
- 'movies': f'{popcorn_base_url}/movie/{{}}',
- 'tv': f'{popcorn_base_url}/show/{{}}',
- }
-
pathlib.Path(self.download_dir).mkdir(parents=True, exist_ok=True)
+ def _load_search_providers(
+ self,
+ search_providers: Optional[
+ Union[Dict[str, dict], Iterable[TorrentSearchProvider]]
+ ],
+ ) -> Iterable[TorrentSearchProvider]:
+ if not search_providers:
+ return []
+
+ parsed_providers = []
+ if isinstance(search_providers, dict):
+ providers_dict = {}
+ provider_classes = {
+ cls.provider_name(): cls
+ for _, cls in inspect.getmembers(search_module, inspect.isclass)
+ if issubclass(cls, TorrentSearchProvider)
+ and cls != TorrentSearchProvider
+ }
+
+ # Configure the search providers explicitly passed in the configuration
+ for provider_name, provider_config in search_providers.items():
+ if provider_name not in provider_classes:
+ self.logger.warning(
+ 'Unsupported search provider %s. Supported providers: %s',
+ provider_name,
+ list(provider_classes.keys()),
+ )
+ continue
+
+ provider_class = provider_classes[provider_name]
+ providers_dict[provider_name] = provider_class(
+ **{'enabled': True, **provider_config}
+ )
+
+ # Enable the search providers that have `default_enabled` set to True
+ # and that are not explicitly disabled
+ for provider_name, provider in provider_classes.items():
+ if provider.default_enabled() and provider_name not in search_providers:
+ providers_dict[provider_name] = provider()
+
+ parsed_providers = list(providers_dict.values())
+ else:
+ parsed_providers = search_providers
+
+ assert all(
+ isinstance(provider, TorrentSearchProvider) for provider in parsed_providers
+ ), 'All search providers must be instances of TorrentSearchProvider'
+
+ return parsed_providers
+
@action
def search(
self,
query: str,
+ providers: Optional[Union[str, Iterable[str]]] = None,
*args,
- category: Optional[Union[str, Iterable[str]]] = None,
- language: Optional[str] = None,
- **kwargs,
+ **filters,
):
"""
Perform a search of video torrents.
:param query: Query string, video name or partial name
- :param category: Category to search. Supported types: "movies", "tv".
- Default: None (search all categories)
- :param language: Language code for the results - example: "en" (default: None, no filter)
+ :param providers: Override the default search providers by specifying
+ the provider names to use for this search.
+ :param filters: Additional filters to apply to the search, depending on
+ what the configured search providers support. For example,
+ ``category`` and ``language`` are supported by the PopcornTime.
+ :return: .. schema:: torrent.TorrentResultSchema(many=True)
"""
results = []
- if isinstance(category, str):
- category = [category]
- def worker(cat):
- if cat not in self.categories:
- raise RuntimeError(
- f'Unsupported category {cat}. Supported categories: '
- f'{list(self.categories.keys())}'
- )
-
- self.logger.info('Searching %s torrents for "%s"', cat, query)
- results.extend(
- self.categories[cat](self, query, *args, language=language, **kwargs)
+ def worker(provider: TorrentSearchProvider):
+ self.logger.debug(
+ 'Searching torrents on provider %s, query: %s',
+ provider.provider_name(),
+ query,
)
+ results.extend(provider.search(query, *args, **filters))
+
+ if providers:
+ providers = [providers] if isinstance(providers, str) else providers
+ search_providers = [
+ provider
+ for provider in self._search_providers
+ if provider.provider_name() in providers
+ ]
+ else:
+ search_providers = self._search_providers
+
+ if not search_providers:
+ self.logger.warning('No search providers enabled')
+ return []
workers = [
- threading.Thread(target=worker, kwargs={'cat': category})
- for category in (category or self.categories.keys())
+ threading.Thread(target=worker, kwargs={'provider': provider})
+ for provider in search_providers
]
for wrk in workers:
@@ -114,170 +205,7 @@ class TorrentPlugin(Plugin):
for wrk in workers:
wrk.join()
- return results
-
- def _imdb_query(self, query: str, category: str):
- if not query:
- return []
-
- if category == 'movies':
- imdb_category = 'movie'
- elif category == 'tv':
- imdb_category = 'tvSeries'
- else:
- raise RuntimeError(f'Unsupported category: {category}')
-
- imdb_url = f'https://v3.sg.media-imdb.com/suggestion/x/{quote_plus(query)}.json?includeVideos=1'
- response = requests.get(imdb_url, timeout=self._http_timeout)
- response.raise_for_status()
- response = response.json()
- assert not response.get('errorMessage'), response['errorMessage']
- return [
- item for item in response.get('d', []) if item.get('qid') == imdb_category
- ]
-
- def _torrent_search_worker(self, imdb_id: str, category: str, q: queue.Queue):
- base_url = self.torrent_base_urls.get(category)
- assert base_url, f'No such category: {category}'
- try:
- results = requests.get(
- base_url.format(imdb_id), timeout=self._http_timeout
- ).json()
- q.put(results)
- except Exception as e:
- q.put(e)
-
- def _search_torrents(self, query, category):
- imdb_results = self._imdb_query(query, category)
- result_queues = [queue.Queue()] * len(imdb_results)
- workers = [
- threading.Thread(
- target=self._torrent_search_worker,
- kwargs={
- 'imdb_id': imdb_results[i]['id'],
- 'category': category,
- 'q': result_queues[i],
- },
- )
- for i in range(len(imdb_results))
- ]
-
- results = []
- errors = []
-
- for worker in workers:
- worker.start()
- for q in result_queues:
- res_ = q.get()
- if isinstance(res_, Exception):
- errors.append(res_)
- else:
- results.append(res_)
- for worker in workers:
- worker.join()
-
- if errors:
- self.logger.warning('Torrent search errors: %s', [str(e) for e in errors])
-
- return results
-
- @staticmethod
- def _results_to_movies_response(
- results: List[dict], language: Optional[str] = None
- ):
- return sorted(
- [
- {
- 'imdb_id': result.get('imdb_id'),
- 'type': 'movies',
- 'file': item.get('file'),
- 'title': (
- result.get('title', '[No Title]')
- + f' [movies][{lang}][{quality}]'
- ),
- 'duration': int(result.get('runtime') or 0) * 60,
- 'year': int(result.get('year') or 0),
- 'synopsis': result.get('synopsis'),
- 'trailer': result.get('trailer'),
- 'genres': result.get('genres', []),
- 'image': result.get('images', {}).get('poster'),
- 'rating': result.get('rating', {}),
- 'language': lang,
- 'quality': quality,
- 'size': item.get('size'),
- 'provider': item.get('provider'),
- 'seeds': item.get('seed'),
- 'peers': item.get('peer'),
- 'url': item.get('url'),
- }
- for result in results
- for (lang, items) in (result.get('torrents', {}) or {}).items()
- if not language or language == lang
- for (quality, item) in items.items()
- if quality != '0'
- ],
- key=lambda item: item.get('seeds', 0),
- reverse=True,
- )
-
- @staticmethod
- def _results_to_tv_response(results: List[dict]):
- return sorted(
- [
- {
- 'imdb_id': result.get('imdb_id'),
- 'tvdb_id': result.get('tvdb_id'),
- 'type': 'tv',
- 'file': item.get('file'),
- 'series': result.get('title'),
- 'title': (
- result.get('title', '[No Title]')
- + f'[S{episode.get("season", 0):02d}E{episode.get("episode", 0):02d}] '
- + f'{episode.get("title", "[No Title]")} [tv][{quality}]'
- ),
- 'duration': int(result.get('runtime') or 0) * 60,
- 'year': int(result.get('year') or 0),
- 'synopsis': result.get('synopsis'),
- 'overview': episode.get('overview'),
- 'season': episode.get('season'),
- 'episode': episode.get('episode'),
- 'num_seasons': result.get('num_seasons'),
- 'country': result.get('country'),
- 'network': result.get('network'),
- 'status': result.get('status'),
- 'genres': result.get('genres', []),
- 'image': result.get('images', {}).get('fanart'),
- 'rating': result.get('rating', {}),
- 'quality': quality,
- 'provider': item.get('provider'),
- 'seeds': item.get('seeds'),
- 'peers': item.get('peers'),
- 'url': item.get('url'),
- }
- for result in results
- for episode in result.get('episodes', [])
- for quality, item in (episode.get('torrents', {}) or {}).items()
- if quality != '0'
- ],
- key=lambda item: (
- '.'.join(
- [
- item.get('series', ''),
- item.get('quality', ''),
- str(item.get('season', 0)).zfill(2),
- str(item.get('episode', 0)).zfill(2),
- ]
- )
- ),
- )
-
- def search_movies(self, query, language=None):
- return self._results_to_movies_response(
- self._search_torrents(query, 'movies'), language=language
- )
-
- def search_tv(self, query, **_):
- return self._results_to_tv_response(self._search_torrents(query, 'tv'))
+ return [result.to_dict() for result in results]
def _get_torrent_info(self, torrent, download_dir):
import libtorrent as lt
@@ -619,10 +547,5 @@ class TorrentPlugin(Plugin):
name += hex(random.randint(0, 15))[2:].upper()
return name + '.torrent'
- categories = {
- 'movies': search_movies,
- 'tv': search_tv,
- }
-
# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/torrent/_search/__init__.py b/platypush/plugins/torrent/_search/__init__.py
new file mode 100644
index 000000000..ceced522f
--- /dev/null
+++ b/platypush/plugins/torrent/_search/__init__.py
@@ -0,0 +1,11 @@
+from ._base import TorrentSearchProvider
+from ._popcorntime import PopcornTimeSearchProvider
+
+
+__all__ = [
+ 'TorrentSearchProvider',
+ 'PopcornTimeSearchProvider',
+]
+
+
+# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/torrent/_search/_base.py b/platypush/plugins/torrent/_search/_base.py
new file mode 100644
index 000000000..713d2b64a
--- /dev/null
+++ b/platypush/plugins/torrent/_search/_base.py
@@ -0,0 +1,64 @@
+from abc import ABC, abstractmethod
+from logging import getLogger
+from typing import Iterable
+
+from ._model import TorrentSearchResult
+
+
+class TorrentSearchProvider(ABC):
+ """
+ Base class for torrent search providers.
+ """
+
+ def __init__(self, **kwargs):
+ if 'enabled' in kwargs:
+ self.enabled = bool(kwargs['enabled'])
+ elif 'disabled' in kwargs:
+ self.enabled = not bool(kwargs['disabled'])
+ else:
+ self.enabled = self.default_enabled()
+
+ self.logger = getLogger(self.__class__.__name__)
+
+ @abstractmethod
+ def _search(self, query: str, *args, **kwargs) -> Iterable[TorrentSearchResult]:
+ """
+ Inner search method. This method should be implemented by subclasses.
+
+ :param query: Query string, torrent name or partial name.
+ """
+ return []
+
+ @classmethod
+ @abstractmethod
+ def provider_name(cls) -> str:
+ """
+ :return: Name of the provider, which can be used to identify it within
+ the :class:`platypush.plugins.torrent.TorrentPlugin`.
+ """
+
+ def search(self, query: str, *args, **kwargs) -> Iterable[TorrentSearchResult]:
+ """
+ Perform a search of torrents.
+
+ :param query: Query string, torrent name or partial name.
+ """
+ if not self.enabled:
+ self.logger.debug(
+ 'Provider %s is disabled, skipping search', self.__class__.__name__
+ )
+ return []
+
+ self.logger.debug('Searching for %r', query)
+ return self._search(query, *args, **kwargs)
+
+ @classmethod
+ def default_enabled(cls) -> bool:
+ """
+ :return: True if the provider is enabled by default, False otherwise.
+ Default: False.
+ """
+ return False
+
+
+# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/torrent/_search/_model.py b/platypush/plugins/torrent/_search/_model.py
new file mode 100644
index 000000000..2375b92ae
--- /dev/null
+++ b/platypush/plugins/torrent/_search/_model.py
@@ -0,0 +1,49 @@
+from dataclasses import dataclass, field
+from typing import List, Optional
+from datetime import datetime
+
+from platypush.schemas.torrent import TorrentResultSchema
+
+
+@dataclass
+class TorrentSearchResult:
+ """
+ Data class for results returned by
+ :meth:`platypush.plugins.torrent.TorrentPlugin.search`.
+ """
+
+ title: str
+ file: Optional[str] = None
+ url: Optional[str] = None
+ provider: Optional[str] = None
+ type: Optional[str] = None
+ size: int = 0
+ duration: float = 0
+ language: Optional[str] = None
+ category: Optional[str] = None
+ seeds: int = 0
+ peers: int = 0
+ image: Optional[str] = None
+ description: Optional[str] = None
+ imdb_id: Optional[str] = None
+ tvdb_id: Optional[str] = None
+ year: Optional[int] = None
+ created_at: Optional[datetime] = None
+ quality: Optional[str] = None
+ overview: Optional[str] = None
+ trailer: Optional[str] = None
+ genres: List[str] = field(default_factory=list)
+ rating: Optional[float] = None
+ critic_rating: Optional[float] = None
+ community_rating: Optional[float] = None
+ votes: Optional[int] = None
+ series: Optional[str] = None
+ season: Optional[int] = None
+ episode: Optional[int] = None
+ num_seasons: Optional[int] = None
+ country: Optional[str] = None
+ network: Optional[str] = None
+ series_status: Optional[str] = None
+
+ def to_dict(self) -> dict:
+ return dict(TorrentResultSchema().dump(self))
diff --git a/platypush/plugins/torrent/_search/_popcorntime.py b/platypush/plugins/torrent/_search/_popcorntime.py
new file mode 100644
index 000000000..eace8fdb7
--- /dev/null
+++ b/platypush/plugins/torrent/_search/_popcorntime.py
@@ -0,0 +1,259 @@
+import queue
+import threading
+from urllib.parse import quote_plus
+from typing import Iterable, List, Optional, Union
+
+import requests
+
+from ._base import TorrentSearchProvider
+from ._model import TorrentSearchResult
+
+
+class PopcornTimeSearchProvider(TorrentSearchProvider):
+ """
+ Torrent search provider that uses the PopcornTime API to search for movies
+ and TV shows.
+ """
+
+ _http_timeout = 20
+
+ def __init__(
+ self,
+ api_url: str,
+ **kwargs,
+ ):
+ """
+ :param api_url: PopcornTime API base URL.
+ """
+ super().__init__(**kwargs)
+ self.api_url = api_url
+ self.torrent_base_urls = {
+ 'movies': f'{api_url}/movie/{{}}',
+ 'tv': f'{api_url}/show/{{}}',
+ }
+
+ @classmethod
+ def provider_name(cls) -> str:
+ return 'popcorntime'
+
+ def _search(
+ self,
+ query: str,
+ *args,
+ category: Optional[Union[str, Iterable[str]]] = None,
+ language: Optional[str] = None,
+ **kwargs,
+ ) -> List[TorrentSearchResult]:
+ """
+ Perform a search of video torrents using the PopcornTime API.
+
+ :param query: Query string, video name or partial name
+ :param category: Category to search. Supported types: `movies`, `tv`.
+ Default: None (search all categories)
+ :param language: Language code for the results - example: "en" (default: None, no filter)
+ """
+
+ results = []
+ if isinstance(category, str):
+ category = [category]
+
+ def worker(cat):
+ if cat not in self.categories:
+ raise RuntimeError(
+ f'Unsupported category {cat}. Supported categories: '
+ f'{list(self.categories.keys())}'
+ )
+
+ self.logger.info('Searching %s torrents for "%s"', cat, query)
+ results.extend(
+ self.categories[cat](self, query, *args, language=language, **kwargs)
+ )
+
+ workers = [
+ threading.Thread(target=worker, kwargs={'cat': category})
+ for category in (category or self.categories.keys())
+ ]
+
+ for wrk in workers:
+ wrk.start()
+ for wrk in workers:
+ wrk.join()
+
+ return results
+
+ def _imdb_query(self, query: str, category: str):
+ if not query:
+ return []
+
+ if category == 'movies':
+ imdb_category = 'movie'
+ elif category == 'tv':
+ imdb_category = 'tvSeries'
+ else:
+ raise RuntimeError(f'Unsupported category: {category}')
+
+ imdb_url = f'https://v3.sg.media-imdb.com/suggestion/x/{quote_plus(query)}.json?includeVideos=1'
+ response = requests.get(imdb_url, timeout=self._http_timeout)
+ response.raise_for_status()
+ response = response.json()
+ assert not response.get('errorMessage'), response['errorMessage']
+ return [
+ item for item in response.get('d', []) if item.get('qid') == imdb_category
+ ]
+
+ def _torrent_search_worker(self, imdb_id: str, category: str, q: queue.Queue):
+ base_url = self.torrent_base_urls.get(category)
+ assert base_url, f'No such category: {category}'
+ try:
+ self.logger.debug('Searching torrents for %s', imdb_id)
+ response = requests.get(
+ base_url.format(imdb_id), timeout=self._http_timeout
+ )
+
+ self.logger.debug('Got response for %s: %s', imdb_id, response.text)
+ result = response.json()
+ q.put(
+ result if result.get('code') != 404 and result.get('imdb_id') else None
+ )
+ except Exception as e:
+ q.put(e)
+
+ def _search_torrents(self, query, category):
+ imdb_results = self._imdb_query(query, category)
+ result_queues = [queue.Queue()] * len(imdb_results)
+ workers = [
+ threading.Thread(
+ target=self._torrent_search_worker,
+ kwargs={
+ 'imdb_id': imdb_results[i]['id'],
+ 'category': category,
+ 'q': result_queues[i],
+ },
+ )
+ for i in range(len(imdb_results))
+ ]
+
+ results = []
+ errors = []
+
+ for worker in workers:
+ worker.start()
+ for q in result_queues:
+ res_ = q.get()
+ if isinstance(res_, Exception):
+ errors.append(res_)
+ else:
+ results.append(res_)
+ for worker in workers:
+ worker.join()
+
+ if errors:
+ self.logger.warning('Torrent search errors: %s', [str(e) for e in errors])
+
+ return results
+
+ @classmethod
+ def _results_to_movies_response(
+ cls, results: List[dict], language: Optional[str] = None
+ ):
+ return sorted(
+ [
+ TorrentSearchResult(
+ provider=cls.provider_name(),
+ imdb_id=result.get('imdb_id'),
+ type='movies',
+ title=result.get('title', '[No Title]')
+ + f' [movies][{lang}][{quality}]',
+ file=item.get('file'),
+ duration=int(result.get('runtime') or 0) * 60,
+ year=int(result.get('year') or 0),
+ description=result.get('synopsis'),
+ trailer=result.get('trailer'),
+ genres=result.get('genres', []),
+ image=result.get('images', {}).get('poster'),
+ rating=result.get('rating', {}).get('percentage'),
+ votes=result.get('rating', {}).get('votes'),
+ language=lang,
+ quality=quality,
+ size=item.get('size'),
+ seeds=item.get('seed'),
+ peers=item.get('peer'),
+ url=item.get('url'),
+ )
+ for result in results
+ for (lang, items) in ((result or {}).get('torrents', {}) or {}).items()
+ if not language or language == lang
+ for (quality, item) in items.items()
+ if result
+ ],
+ key=lambda item: item.seeds,
+ reverse=True,
+ )
+
+ @classmethod
+ def _results_to_tv_response(cls, results: List[dict]):
+ return sorted(
+ [
+ TorrentSearchResult(
+ provider=cls.provider_name(),
+ imdb_id=result.get('imdb_id'),
+ tvdb_id=result.get('tvdb_id'),
+ type='tv',
+ file=item.get('file'),
+ series=result.get('title'),
+ title=(
+ result.get('title', '[No Title]')
+ + f'[S{episode.get("season", 0):02d}E{episode.get("episode", 0):02d}] '
+ + f'{episode.get("title", "[No Title]")} [tv][{quality}]'
+ ),
+ duration=int(result.get('runtime') or 0) * 60,
+ year=int(result.get('year') or 0),
+ description=result.get('synopsis'),
+ overview=episode.get('overview'),
+ season=episode.get('season'),
+ episode=episode.get('episode'),
+ num_seasons=result.get('num_seasons'),
+ country=result.get('country'),
+ network=result.get('network'),
+ series_status=result.get('status'),
+ genres=result.get('genres', []),
+ image=result.get('images', {}).get('fanart'),
+ rating=result.get('rating', {}).get('percentage'),
+ votes=result.get('rating', {}).get('votes'),
+ quality=quality,
+ seeds=item.get('seeds'),
+ peers=item.get('peers'),
+ url=item.get('url'),
+ )
+ for result in results
+ for episode in (result or {}).get('episodes', [])
+ for quality, item in (episode.get('torrents', {}) or {}).items()
+ if quality != '0'
+ ],
+ key=lambda item: ( # type: ignore
+ '.'.join(
+ [ # type: ignore
+ (item.series or ''),
+ (item.quality or ''),
+ str(item.season or 0).zfill(2),
+ str(item.episode or 0).zfill(2),
+ ]
+ )
+ ),
+ )
+
+ def search_movies(self, query, language=None):
+ return self._results_to_movies_response(
+ self._search_torrents(query, 'movies'), language=language
+ )
+
+ def search_tv(self, query, **_):
+ return self._results_to_tv_response(self._search_torrents(query, 'tv'))
+
+ categories = {
+ 'movies': search_movies,
+ 'tv': search_tv,
+ }
+
+
+# vim:sw=4:ts=4:et:
diff --git a/platypush/schemas/__init__.py b/platypush/schemas/__init__.py
index 6d12ce03e..c81d01540 100644
--- a/platypush/schemas/__init__.py
+++ b/platypush/schemas/__init__.py
@@ -14,7 +14,9 @@ class StrippedString(fields.Function): # lgtm [py/missing-call-to-init]
super().__init__(*args, **kwargs)
def _serialize(self, value, attr, obj, **kwargs) -> Optional[str]:
- if obj.get(attr) is not None:
+ if getattr(obj, attr, None) is not None:
+ return self._strip(getattr(obj, attr))
+ if getattr(obj, 'get', lambda _: None)(attr) is not None:
return self._strip(obj.get(attr))
@staticmethod
diff --git a/platypush/schemas/torrent.py b/platypush/schemas/torrent.py
new file mode 100644
index 000000000..d263c36ce
--- /dev/null
+++ b/platypush/schemas/torrent.py
@@ -0,0 +1,252 @@
+from marshmallow import fields, EXCLUDE, INCLUDE
+from marshmallow.schema import Schema
+
+from platypush.schemas import DateTime, StrippedString
+
+
+class TorrentResultSchema(Schema):
+ """
+ Schema for results returned by
+ :meth:`platypush.plugins.torrent.TorrentPlugin.search`.
+ """
+
+ # pylint: disable=too-few-public-methods
+ class Meta(Schema.Meta):
+ """
+ Schema metadata.
+ """
+
+ missing = EXCLUDE
+ ordered = True
+ unknown = INCLUDE
+
+ title = StrippedString(
+ required=True,
+ metadata={
+ 'description': 'Title of the torrent',
+ 'example': '2001: A Space Odyssey',
+ },
+ )
+
+ file = StrippedString(
+ metadata={
+ 'description': (
+ 'File name of the torrent, if the resource is a local .torrent file'
+ ),
+ 'example': '/home/user/downloads/2001_a_space_odyssey.torrent',
+ },
+ )
+
+ url = StrippedString(
+ metadata={
+ 'description': 'URL of the torrent, if the resource is a remote torrent',
+ 'example': 'magnet:?xt=urn:btih:...',
+ },
+ )
+
+ provider = StrippedString(
+ metadata={
+ 'description': (
+ 'Provider of the torrent - e.g. `popcorntime`, `yts` or `torrent-csv`'
+ ),
+ 'example': 'popcorntime',
+ },
+ )
+
+ type = StrippedString(
+ metadata={
+ 'description': 'Type of the torrent',
+ 'example': 'movies',
+ },
+ )
+
+ size = fields.Integer(
+ missing=0,
+ metadata={
+ 'description': 'Size of the torrent in bytes',
+ 'example': 123456789,
+ },
+ )
+
+ duration = fields.Float(
+ metadata={
+ 'description': 'Duration of the torrent in seconds, if applicable',
+ 'example': 123.45,
+ },
+ )
+
+ language = StrippedString(
+ metadata={
+ 'description': 'Language of the torrent',
+ 'example': 'en',
+ },
+ )
+
+ seeds = fields.Integer(
+ missing=0,
+ metadata={
+ 'description': 'Number of seeders',
+ 'example': 123,
+ },
+ )
+
+ peers = fields.Integer(
+ missing=0,
+ metadata={
+ 'description': 'Number of peers',
+ 'example': 123,
+ },
+ )
+
+ image = StrippedString(
+ metadata={
+ 'description': 'URL of the image associated to the torrent',
+ 'example': 'https://example.com/image.jpg',
+ },
+ )
+
+ description = StrippedString(
+ metadata={
+ 'description': 'Description of the torrent',
+ 'example': 'A description of the torrent',
+ },
+ )
+
+ imdb_id = StrippedString(
+ metadata={
+ 'description': 'IMDb ID of the torrent, if applicable',
+ 'example': 'tt0062622',
+ },
+ )
+
+ tvdb_id = StrippedString(
+ metadata={
+ 'description': 'TVDB ID of the torrent, if applicable',
+ 'example': '76283',
+ },
+ )
+
+ year = fields.Integer(
+ metadata={
+ 'description': 'Year of the release of the underlying product, if applicable',
+ 'example': 1968,
+ },
+ )
+
+ created_at = DateTime(
+ metadata={
+ 'description': 'Creation date of the torrent',
+ 'example': '2021-01-01T00:00:00',
+ },
+ )
+
+ quality = StrippedString(
+ metadata={
+ 'description': 'Quality of the torrent, if video/audio',
+ 'example': '1080p',
+ },
+ )
+
+ overview = StrippedString(
+ metadata={
+ 'description': 'Overview of the torrent, if it is a TV show',
+ 'example': 'Overview of the torrent, if it is a TV show',
+ },
+ )
+
+ trailer = fields.URL(
+ metadata={
+ 'description': 'URL of the trailer of the torrent, if available',
+ 'example': 'https://example.com/trailer.mp4',
+ },
+ )
+
+ genres = fields.List(
+ fields.String(),
+ metadata={
+ 'description': 'List of genres associated to the torrent',
+ 'example': ['Sci-Fi', 'Adventure'],
+ },
+ )
+
+ rating = fields.Float(
+ metadata={
+ 'description': (
+ 'Rating of the torrent or the underlying product, as a '
+ 'percentage between 0 and 100',
+ ),
+ 'example': 86.0,
+ },
+ )
+
+ critic_rating = fields.Float(
+ metadata={
+ 'description': 'Critic rating, if applicable, as a percentage between 0 and 100',
+ 'example': 86.0,
+ },
+ )
+
+ community_rating = fields.Float(
+ metadata={
+ 'description': (
+ 'Community rating, if applicable, as a percentage between 0 and 100'
+ ),
+ 'example': 86.0,
+ },
+ )
+
+ votes = fields.Integer(
+ metadata={
+ 'description': 'Number of votes, if applicable',
+ 'example': 123,
+ },
+ )
+
+ series = StrippedString(
+ metadata={
+ 'description': 'Title of the TV series, if applicable',
+ 'example': 'Breaking Bad',
+ },
+ )
+
+ season = fields.Integer(
+ metadata={
+ 'description': 'Season number of the TV series, if applicable',
+ 'example': 1,
+ },
+ )
+
+ episode = fields.Integer(
+ metadata={
+ 'description': 'Episode number of the TV series, if applicable',
+ 'example': 1,
+ },
+ )
+
+ num_seasons = fields.Integer(
+ metadata={
+ 'description': 'Number of seasons of the TV series, if applicable',
+ 'example': 5,
+ },
+ )
+
+ country = StrippedString(
+ metadata={
+ 'description': 'Country of origin of the torrent or the underlying product',
+ 'example': 'USA',
+ },
+ )
+
+ network = StrippedString(
+ metadata={
+ 'description': 'Network of the TV series, if applicable',
+ 'example': 'AMC',
+ },
+ )
+
+ series_status = StrippedString(
+ metadata={
+ 'description': 'Status of the TV series, if applicable',
+ 'example': 'Ended',
+ },
+ )