[torrent] Updated plugin.

- The default PopcornTime API host has changed, as popcorn-time.ga is no
  longer available.

- The iMDb API now requires a paid tier even for a basic query. The
  official iMDb API layer (and the API key requirement) has thus been
  replaced with a dear ol' scraping of the frontend endpoint.

- Pass of Black/LINT.
This commit is contained in:
Fabio Manganiello 2023-11-03 21:53:33 +01:00
parent 35571b8d13
commit 4c5366849d

View file

@ -1,11 +1,12 @@
import os import os
import queue import queue
import random import random
from typing import List, Optional
import requests
import threading import threading
import time import time
from urllib.parse import quote_plus
from typing import Iterable, List, Optional, Union
import requests
from platypush.context import get_bus from platypush.context import get_bus
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
@ -20,6 +21,7 @@ from platypush.message.event.torrent import (
TorrentResumedEvent, TorrentResumedEvent,
TorrentQueuedEvent, TorrentQueuedEvent,
) )
from platypush.utils import get_default_downloads_dir
class TorrentPlugin(Plugin): class TorrentPlugin(Plugin):
@ -27,57 +29,36 @@ class TorrentPlugin(Plugin):
Plugin to search and download torrents. Plugin to search and download torrents.
""" """
_http_timeout = 20
# Wait time in seconds between two torrent transfer checks # Wait time in seconds between two torrent transfer checks
_MONITOR_CHECK_INTERVAL = 3 _MONITOR_CHECK_INTERVAL = 3
default_torrent_ports = [6881, 6891] default_torrent_ports = (6881, 6891)
categories = {
'movies': None,
'tv': None,
}
torrent_state = {} torrent_state = {}
transfers = {} transfers = {}
# noinspection HttpUrlsUsage default_popcorn_base_url = 'https://shows.cf'
default_popcorn_base_url = 'http://popcorn-time.ga'
def __init__( def __init__(
self, self,
download_dir=None, download_dir: Optional[str] = None,
torrent_ports=None, torrent_ports: Iterable[int] = default_torrent_ports,
imdb_key=None, popcorn_base_url: str = default_popcorn_base_url,
popcorn_base_url=default_popcorn_base_url,
**kwargs, **kwargs,
): ):
""" """
:param download_dir: Directory where the videos/torrents will be downloaded (default: none) :param download_dir: Directory where the videos/torrents will be
:type download_dir: str downloaded (default: ``~/Downloads``).
:param torrent_ports: Torrent ports to listen on (default: 6881 and 6891) :param torrent_ports: Torrent ports to listen on (default: 6881 and 6891)
:type torrent_ports: list[int]
:param imdb_key: The IMDb API (https://imdb-api.com/) is used to search for movies and series. Insert your
IMDb API key if you want support for content search.
:type imdb_key: str
:param popcorn_base_url: Custom base URL to use for the PopcornTime API. :param popcorn_base_url: Custom base URL to use for the PopcornTime API.
:type popcorn_base_url: str
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
if torrent_ports is None: self.torrent_ports = torrent_ports
torrent_ports = [] self.download_dir = os.path.abspath(
os.path.expanduser(download_dir or get_default_downloads_dir())
for category in self.categories.keys():
self.categories[category] = getattr(self, 'search_' + category)
self.imdb_key = imdb_key
self.imdb_urls = {}
self.torrent_ports = (
torrent_ports if torrent_ports else self.default_torrent_ports
) )
self.download_dir = None
self._sessions = {} self._sessions = {}
self._lt_session = None self._lt_session = None
self.popcorn_base_url = popcorn_base_url self.popcorn_base_url = popcorn_base_url
@ -86,48 +67,38 @@ class TorrentPlugin(Plugin):
'tv': f'{popcorn_base_url}/show/{{}}', 'tv': f'{popcorn_base_url}/show/{{}}',
} }
if imdb_key:
self.imdb_urls = {
'movies': f'https://imdb-api.com/API/SearchMovie/{self.imdb_key}/{{}}',
'tv': f'https://imdb-api.com/API/SearchSeries/{self.imdb_key}/{{}}',
}
if download_dir:
self.download_dir = os.path.abspath(os.path.expanduser(download_dir))
@action @action
def search(self, query, category=None, language=None, *args, **kwargs): def search(
self,
query: str,
*args,
category: Optional[Union[str, Iterable[str]]] = None,
language: Optional[str] = None,
**kwargs,
):
""" """
Perform a search of video torrents. Perform a search of video torrents.
:param query: Query string, video name or partial name :param query: Query string, video name or partial name
:type query: str
:param category: Category to search. Supported types: "movies", "tv". :param category: Category to search. Supported types: "movies", "tv".
Default: None (search all categories) Default: None (search all categories)
:type category: str or list
:param language: Language code for the results - example: "en" (default: None, no filter) :param language: Language code for the results - example: "en" (default: None, no filter)
:type language: str
""" """
assert self.imdb_key, 'No imdb_key specified'
results = [] results = []
if isinstance(category, str): if isinstance(category, str):
category = [category] category = [category]
# noinspection PyCallingNonCallable
def worker(cat): def worker(cat):
if cat not in self.categories: if cat not in self.categories:
raise RuntimeError( raise RuntimeError(
'Unsupported category {}. Supported category: {}'.format( f'Unsupported category {cat}. Supported categories: '
cat, self.categories.keys() f'{list(self.categories.keys())}'
)
) )
self.logger.info('Searching {} torrents for "{}"'.format(cat, query)) self.logger.info('Searching %s torrents for "%s"', cat, query)
results.extend( results.extend(
self.categories[cat](query, *args, language=language, **kwargs) self.categories[cat](self, query, *args, language=language, **kwargs)
) )
workers = [ workers = [
@ -135,29 +106,40 @@ class TorrentPlugin(Plugin):
for category in (category or self.categories.keys()) for category in (category or self.categories.keys())
] ]
for worker in workers: for wrk in workers:
worker.start() wrk.start()
for worker in workers: for wrk in workers:
worker.join() wrk.join()
return results return results
def _imdb_query(self, query: str, category: str): def _imdb_query(self, query: str, category: str):
assert self.imdb_key, 'No imdb_key specified' if not query:
imdb_url = self.imdb_urls.get(category) return []
assert imdb_url, f'No such category: {category}' if category == 'movies':
imdb_url = imdb_url.format(query) imdb_category = 'movie'
response = requests.get(imdb_url).json() 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'] assert not response.get('errorMessage'), response['errorMessage']
return response.get('results', []) 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): def _torrent_search_worker(self, imdb_id: str, category: str, q: queue.Queue):
base_url = self.torrent_base_urls.get(category) base_url = self.torrent_base_urls.get(category)
assert base_url, f'No such category: {category}' assert base_url, f'No such category: {category}'
try: try:
results = requests.get(base_url.format(imdb_id)).json() results = requests.get(
base_url.format(imdb_id), timeout=self._http_timeout
).json()
q.put(results) q.put(results)
except Exception as e: except Exception as e:
q.put(e) q.put(e)
@ -192,7 +174,7 @@ class TorrentPlugin(Plugin):
worker.join() worker.join()
if errors: if errors:
self.logger.warning(f'Torrent search errors: {[str(e) for e in errors]}') self.logger.warning('Torrent search errors: %s', [str(e) for e in errors])
return results return results
@ -206,8 +188,9 @@ class TorrentPlugin(Plugin):
'imdb_id': result.get('imdb_id'), 'imdb_id': result.get('imdb_id'),
'type': 'movies', 'type': 'movies',
'file': item.get('file'), 'file': item.get('file'),
'title': '{title} [movies][{language}][{quality}]'.format( 'title': (
title=result.get('title'), language=lang, quality=quality result.get('title', '[No Title]')
+ f' [movies][{lang}][{quality}]'
), ),
'duration': int(result.get('runtime') or 0), 'duration': int(result.get('runtime') or 0),
'year': int(result.get('year') or 0), 'year': int(result.get('year') or 0),
@ -244,12 +227,10 @@ class TorrentPlugin(Plugin):
'type': 'tv', 'type': 'tv',
'file': item.get('file'), 'file': item.get('file'),
'series': result.get('title'), 'series': result.get('title'),
'title': '{series} [S{season:02d}E{episode:02d}] {title} [tv][{quality}]'.format( 'title': (
series=result.get('title'), result.get('title', '[No Title]')
season=episode.get('season'), + f'[S{episode.get("season", 0):02d}E{episode.get("episode", 0):02d}] '
episode=episode.get('episode'), + f'{episode.get("title", "[No Title]")} [tv][{quality}]'
title=episode.get('title'),
quality=quality,
), ),
'duration': int(result.get('runtime') or 0), 'duration': int(result.get('runtime') or 0),
'year': int(result.get('year') or 0), 'year': int(result.get('year') or 0),
@ -275,11 +256,15 @@ class TorrentPlugin(Plugin):
for quality, item in (episode.get('torrents', {}) or {}).items() for quality, item in (episode.get('torrents', {}) or {}).items()
if quality != '0' if quality != '0'
], ],
key=lambda item: '{series}.{quality}.{season:02d}.{episode:02d}'.format( key=lambda item: (
series=item.get('series'), '.'.join(
quality=item.get('quality'), [
season=item.get('season'), item.get('series', ''),
episode=item.get('episode'), item.get('quality', ''),
str(item.get('season', 0)).zfill(2),
str(item.get('episode', 0)).zfill(2),
]
)
), ),
) )
@ -299,7 +284,6 @@ class TorrentPlugin(Plugin):
info = {} info = {}
file_info = {} file_info = {}
# noinspection HttpUrlsUsage
if torrent.startswith('magnet:?'): if torrent.startswith('magnet:?'):
magnet = torrent magnet = torrent
magnet_info = lt.parse_magnet_uri(magnet) magnet_info = lt.parse_magnet_uri(magnet)
@ -320,7 +304,9 @@ class TorrentPlugin(Plugin):
'save_path': download_dir, 'save_path': download_dir,
} }
elif torrent.startswith('http://') or torrent.startswith('https://'): elif torrent.startswith('http://') or torrent.startswith('https://'):
response = requests.get(torrent, allow_redirects=True) response = requests.get(
torrent, timeout=self._http_timeout, allow_redirects=True
)
torrent_file = os.path.join(download_dir, self._generate_rand_filename()) torrent_file = os.path.join(download_dir, self._generate_rand_filename())
with open(torrent_file, 'wb') as f: with open(torrent_file, 'wb') as f:
@ -328,13 +314,10 @@ class TorrentPlugin(Plugin):
else: else:
torrent_file = os.path.abspath(os.path.expanduser(torrent)) torrent_file = os.path.abspath(os.path.expanduser(torrent))
if not os.path.isfile(torrent_file): if not os.path.isfile(torrent_file):
raise RuntimeError( raise RuntimeError(f'{torrent_file} is not a valid torrent file')
'{} is not a valid torrent file'.format(torrent_file)
)
if torrent_file: if torrent_file:
file_info = lt.torrent_info(torrent_file) file_info = lt.torrent_info(torrent_file)
# noinspection PyArgumentList
info = { info = {
'name': file_info.name(), 'name': file_info.name(),
'url': torrent, 'url': torrent,
@ -352,7 +335,7 @@ class TorrentPlugin(Plugin):
if event_hndl: if event_hndl:
event_hndl(event) event_hndl(event)
except Exception as e: except Exception as e:
self.logger.warning('Exception in torrent event handler: {}'.format(str(e))) self.logger.warning('Exception in torrent event handler: %s', e)
self.logger.exception(e) self.logger.exception(e)
def _torrent_monitor(self, torrent, transfer, download_dir, event_hndl, is_media): def _torrent_monitor(self, torrent, transfer, download_dir, event_hndl, is_media):
@ -364,9 +347,7 @@ class TorrentPlugin(Plugin):
while not transfer.is_finished(): while not transfer.is_finished():
if torrent not in self.transfers: if torrent not in self.transfers:
self.logger.info( self.logger.info('Torrent %s has been stopped and removed', torrent)
'Torrent {} has been stopped and removed'.format(torrent)
)
self._fire_event(TorrentDownloadStopEvent(url=torrent), event_hndl) self._fire_event(TorrentDownloadStopEvent(url=torrent), event_hndl)
break break
@ -383,7 +364,6 @@ class TorrentPlugin(Plugin):
if is_media: if is_media:
from platypush.plugins.media import MediaPlugin from platypush.plugins.media import MediaPlugin
# noinspection PyProtectedMember
files = [f for f in files if MediaPlugin.is_video_file(f)] files = [f for f in files if MediaPlugin.is_video_file(f)]
self.torrent_state[torrent]['download_rate'] = status.download_rate self.torrent_state[torrent]['download_rate'] = status.download_rate
@ -458,7 +438,6 @@ class TorrentPlugin(Plugin):
import libtorrent as lt import libtorrent as lt
# noinspection PyArgumentList
self._lt_session = lt.session() self._lt_session = lt.session()
return self._lt_session return self._lt_session
@ -512,9 +491,7 @@ class TorrentPlugin(Plugin):
) )
if torrent in self._sessions: if torrent in self._sessions:
self.logger.info( self.logger.info('A torrent session is already running for %s', torrent)
'A torrent session is already running for {}'.format(torrent)
)
return self.torrent_state.get(torrent, {}) return self.torrent_state.get(torrent, {})
session = self._get_session() session = self._get_session()
@ -543,9 +520,7 @@ class TorrentPlugin(Plugin):
self._fire_event(TorrentQueuedEvent(url=torrent), event_hndl) self._fire_event(TorrentQueuedEvent(url=torrent), event_hndl)
self.logger.info( self.logger.info(
'Downloading "{}" to "{}" from [{}]'.format( 'Downloading "%s" to "%s" from [%s]', info['name'], download_dir, torrent
info['name'], download_dir, torrent
)
) )
monitor_thread = self._torrent_monitor( monitor_thread = self._torrent_monitor(
torrent=torrent, torrent=torrent,
@ -587,7 +562,7 @@ class TorrentPlugin(Plugin):
""" """
if torrent not in self.transfers: if torrent not in self.transfers:
return None, "No transfer in progress for {}".format(torrent) return None, f"No transfer in progress for {torrent}"
if self.torrent_state[torrent].get('paused', False): if self.torrent_state[torrent].get('paused', False):
self.transfers[torrent].resume() self.transfers[torrent].resume()
@ -605,9 +580,7 @@ class TorrentPlugin(Plugin):
:type torrent: str :type torrent: str
""" """
if torrent not in self.transfers: assert torrent in self.transfers, f"No transfer in progress for {torrent}"
return None, "No transfer in progress for {}".format(torrent)
self.transfers[torrent].resume() self.transfers[torrent].resume()
@action @action
@ -619,13 +592,11 @@ class TorrentPlugin(Plugin):
:type torrent: str :type torrent: str
""" """
if torrent not in self.transfers: assert torrent in self.transfers, f"No transfer in progress for {torrent}"
return None, "No transfer in progress for {}".format(torrent)
self.transfers[torrent].pause() self.transfers[torrent].pause()
del self.torrent_state[torrent] del self.torrent_state[torrent]
del self.transfers[torrent] del self.transfers[torrent]
if torrent in self._sessions: if torrent in self._sessions:
del self._sessions[torrent] del self._sessions[torrent]
@ -645,5 +616,10 @@ class TorrentPlugin(Plugin):
name += hex(random.randint(0, 15))[2:].upper() name += hex(random.randint(0, 15))[2:].upper()
return name + '.torrent' return name + '.torrent'
categories = {
'movies': search_movies,
'tv': search_tv,
}
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et: