From 8fc3201b8c30f6c6bb25aa18c2733586e3ca97cf Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 9 Jun 2024 00:02:04 +0200 Subject: [PATCH] [torrent] Refactored torrent search. Allow for more torrent search providers other than PopcornTime (in preparation for torrent-csv). --- CHANGELOG.md | 4 +- .../src/components/panels/Media/Info.vue | 11 +- platypush/plugins/torrent/__init__.py | 327 +++++++----------- platypush/plugins/torrent/_search/__init__.py | 11 + platypush/plugins/torrent/_search/_base.py | 64 ++++ platypush/plugins/torrent/_search/_model.py | 49 +++ .../plugins/torrent/_search/_popcorntime.py | 259 ++++++++++++++ platypush/schemas/__init__.py | 4 +- platypush/schemas/torrent.py | 252 ++++++++++++++ 9 files changed, 768 insertions(+), 213 deletions(-) create mode 100644 platypush/plugins/torrent/_search/__init__.py create mode 100644 platypush/plugins/torrent/_search/_base.py create mode 100644 platypush/plugins/torrent/_search/_model.py create mode 100644 platypush/plugins/torrent/_search/_popcorntime.py create mode 100644 platypush/schemas/torrent.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8014cf5b6..5f8a35e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,12 @@ - [[#405](https://git.platypush.tech/platypush/platypush/issues/405)] Fixed timezone/timestamp rendering issues for `calendar.ical` events. -- [[#403]((https://git.platypush.tech/platypush/platypush/issues/403))] +- [[#403]((https://git.platypush.tech/platypush/platypush/issues/403)] Included inherited actions in plugins docs. ## [1.0.7] - 2024-06-02 -- [[#384]((https://git.platypush.tech/platypush/platypush/issues/384))] Added +- [[#384]((https://git.platypush.tech/platypush/platypush/issues/384)] Added `assistant.openai` and `tts.openai` plugins. ## [1.0.6] - 2024-06-01 diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Info.vue b/platypush/backend/http/webapp/src/components/panels/Media/Info.vue index 3d8e5560c..09ac0ed28 100644 --- a/platypush/backend/http/webapp/src/components/panels/Media/Info.vue +++ b/platypush/backend/http/webapp/src/components/panels/Media/Info.vue @@ -36,11 +36,6 @@
-
-
Synopsis
-
-
-
Description
@@ -80,7 +75,7 @@
Rating
-
{{ item.rating.percentage }}%
+
{{ item.rating }}%
@@ -93,9 +88,9 @@
{{ item.community_rating }}%
-
+
Votes
-
+
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', + }, + )