Migrated the webapp to Tornado.

It was just too painful to find a combination of versions of  gunicorn,
gevent, eventlet, pyuwsgi etc. that could work on all of my systems.

On the other hand, Tornado works out of the box with no headaches.

Also in this commit:

- Updated a bunch of outdated/required integration dependencies.
- Black'd and LINTed a couple of old plugins.
This commit is contained in:
Fabio Manganiello 2023-05-07 19:38:40 +02:00
parent f81e9061a3
commit 56dc8d0972
Signed by: blacklight
GPG key ID: D90FBA7F76362774
33 changed files with 837 additions and 438 deletions

View file

@ -304,6 +304,7 @@ autodoc_mock_imports = [
'TheengsDecoder',
'simple_websocket',
'uvicorn',
'websockets',
]
sys.path.insert(0, os.path.abspath('../..'))

View file

@ -8,11 +8,22 @@ import os
import time
from platypush.backend.assistant import AssistantBackend
from platypush.message.event.assistant import \
ConversationStartEvent, ConversationEndEvent, ConversationTimeoutEvent, \
ResponseEvent, NoResponseEvent, SpeechRecognizedEvent, AlarmStartedEvent, \
AlarmEndEvent, TimerStartedEvent, TimerEndEvent, AlertStartedEvent, \
AlertEndEvent, MicMutedEvent, MicUnmutedEvent
from platypush.message.event.assistant import (
ConversationStartEvent,
ConversationEndEvent,
ConversationTimeoutEvent,
ResponseEvent,
NoResponseEvent,
SpeechRecognizedEvent,
AlarmStartedEvent,
AlarmEndEvent,
TimerStartedEvent,
TimerEndEvent,
AlertStartedEvent,
AlertEndEvent,
MicMutedEvent,
MicUnmutedEvent,
)
class AssistantGoogleBackend(AssistantBackend):
@ -57,22 +68,30 @@ class AssistantGoogleBackend(AssistantBackend):
* **google-assistant-library** (``pip install google-assistant-library``)
* **google-assistant-sdk[samples]** (``pip install google-assistant-sdk[samples]``)
* **google-auth** (``pip install google-auth``)
"""
def __init__(self,
credentials_file=os.path.join(
os.path.expanduser('~/.config'),
'google-oauthlib-tool', 'credentials.json'),
device_model_id='Platypush', **kwargs):
_default_credentials_file = os.path.join(
os.path.expanduser('~/.config'), 'google-oauthlib-tool', 'credentials.json'
)
def __init__(
self,
credentials_file=_default_credentials_file,
device_model_id='Platypush',
**kwargs
):
"""
:param credentials_file: Path to the Google OAuth credentials file \
(default: ~/.config/google-oauthlib-tool/credentials.json). \
See https://developers.google.com/assistant/sdk/guides/library/python/embed/install-sample#generate_credentials \
:param credentials_file: Path to the Google OAuth credentials file
(default: ~/.config/google-oauthlib-tool/credentials.json).
See
https://developers.google.com/assistant/sdk/guides/library/python/embed/install-sample#generate_credentials
for instructions to get your own credentials file.
:type credentials_file: str
:param device_model_id: Device model ID to use for the assistant \
:param device_model_id: Device model ID to use for the assistant
(default: Platypush)
:type device_model_id: str
"""
@ -102,17 +121,23 @@ class AssistantGoogleBackend(AssistantBackend):
self.bus.post(ConversationTimeoutEvent(assistant=self))
elif event.type == EventType.ON_NO_RESPONSE:
self.bus.post(NoResponseEvent(assistant=self))
elif hasattr(EventType, 'ON_RENDER_RESPONSE') and \
event.type == EventType.ON_RENDER_RESPONSE:
self.bus.post(ResponseEvent(assistant=self, response_text=event.args.get('text')))
elif (
hasattr(EventType, 'ON_RENDER_RESPONSE')
and event.type == EventType.ON_RENDER_RESPONSE
):
self.bus.post(
ResponseEvent(assistant=self, response_text=event.args.get('text'))
)
tts, args = self._get_tts_plugin()
if tts and 'text' in event.args:
self.stop_conversation()
tts.say(text=event.args['text'], **args)
elif hasattr(EventType, 'ON_RESPONDING_STARTED') and \
event.type == EventType.ON_RESPONDING_STARTED and \
event.args.get('is_error_response', False) is True:
elif (
hasattr(EventType, 'ON_RESPONDING_STARTED')
and event.type == EventType.ON_RESPONDING_STARTED
and event.args.get('is_error_response', False) is True
):
self.logger.warning('Assistant response error')
elif event.type == EventType.ON_RECOGNIZING_SPEECH_FINISHED:
phrase = event.args['text'].lower().strip()
@ -144,12 +169,12 @@ class AssistantGoogleBackend(AssistantBackend):
self.bus.post(event)
def start_conversation(self):
""" Starts an assistant conversation """
"""Starts an assistant conversation"""
if self.assistant:
self.assistant.start_conversation()
def stop_conversation(self):
""" Stops an assistant conversation """
"""Stops an assistant conversation"""
if self.assistant:
self.assistant.stop_conversation()
@ -177,7 +202,9 @@ class AssistantGoogleBackend(AssistantBackend):
super().run()
with open(self.credentials_file, 'r') as f:
self.credentials = google.oauth2.credentials.Credentials(token=None, **json.load(f))
self.credentials = google.oauth2.credentials.Credentials(
token=None, **json.load(f)
)
while not self.should_stop():
self._has_error = False
@ -186,12 +213,16 @@ class AssistantGoogleBackend(AssistantBackend):
self.assistant = assistant
for event in assistant.start():
if not self.is_detecting():
self.logger.info('Assistant event received but detection is currently paused')
self.logger.info(
'Assistant event received but detection is currently paused'
)
continue
self._process_event(event)
if self._has_error:
self.logger.info('Restarting the assistant after an unrecoverable error')
self.logger.info(
'Restarting the assistant after an unrecoverable error'
)
time.sleep(5)
break

View file

@ -22,5 +22,6 @@ manifest:
pip:
- google-assistant-library
- google-assistant-sdk[samples]
- google-auth
package: platypush.backend.assistant.google
type: backend

View file

@ -3,5 +3,7 @@ manifest:
install:
pip:
- picamera
- numpy
- Pillow
package: platypush.backend.camera.pi
type: backend

View file

@ -3,13 +3,17 @@ import pathlib
import secrets
import threading
from multiprocessing import Process, cpu_count
from multiprocessing import Process
from typing import Mapping, Optional
from tornado.wsgi import WSGIContainer
from tornado.web import Application, FallbackHandler
from tornado.ioloop import IOLoop
from platypush.backend import Backend
from platypush.backend.http.app import application
from platypush.backend.http.ws import events_redis_topic
from platypush.backend.http.wsgi import WSGIApplicationWrapper
from platypush.backend.http.ws import WSEventProxy, events_redis_topic
from platypush.bus.redis import RedisBus
from platypush.config import Config
from platypush.utils import get_redis
@ -177,6 +181,7 @@ class HttpBackend(Backend):
self.server_proc = None
self._service_registry_thread = None
self.bind_address = bind_address
self._io_loop: Optional[IOLoop] = None
if resource_dirs:
self.resource_dirs = {
@ -200,6 +205,10 @@ class HttpBackend(Backend):
super().on_stop()
self.logger.info('Received STOP event on HttpBackend')
if self._io_loop:
self._io_loop.stop()
self._io_loop.close()
if self.server_proc:
self.server_proc.terminate()
self.server_proc.join(timeout=10)
@ -216,6 +225,8 @@ class HttpBackend(Backend):
self._service_registry_thread.join(timeout=5)
self._service_registry_thread = None
self._io_loop = None
def notify_web_clients(self, event):
"""Notify all the connected web clients (over websocket) of a new event"""
get_redis().publish(events_redis_topic, str(event))
@ -248,14 +259,23 @@ class HttpBackend(Backend):
application.config['redis_queue'] = self.bus.redis_queue
application.secret_key = self._get_secret_key()
kwargs = {
'bind': f'{self.bind_address}:{self.port}',
'workers': (cpu_count() * 2) + 1,
'worker_class_str': f'{__package__}.app.UvicornWorker',
'timeout': 30,
}
WSGIApplicationWrapper(f'{__package__}.app:application', kwargs).run()
container = WSGIContainer(application)
server = Application(
[
(r'/ws/events', WSEventProxy),
(r'.*', FallbackHandler, {'fallback': container}),
]
)
server.listen(address=self.bind_address, port=self.port)
self._io_loop = IOLoop.instance()
try:
self._io_loop.start()
except Exception as e:
if not self.should_stop():
raise e
return proc

View file

@ -1,65 +0,0 @@
from logging import getLogger
from flask import Blueprint, request
from simple_websocket import ConnectionClosed, Server
from platypush.backend.http.app import template_folder
from platypush.backend.http.app.utils import authenticate
from platypush.backend.http.ws import events_redis_topic
from platypush.message.event import Event
from platypush.utils import get_redis
ws = Blueprint('ws', __name__, template_folder=template_folder)
__routes__ = [ws]
logger = getLogger(__name__)
@ws.route('/ws/events', websocket=True)
@authenticate(json=True)
def ws_events_route():
"""
A websocket endpoint to asynchronously receive events generated from the
application.
This endpoint is mainly used by web clients to listen for the events
generated by the application.
"""
sock = Server(request.environ, ping_interval=25)
ws_key = (sock.environ['REMOTE_ADDR'], int(sock.environ['REMOTE_PORT']))
sub = get_redis().pubsub()
sub.subscribe(events_redis_topic)
logger.info('Started websocket connection with %s', ws_key)
try:
for msg in sub.listen():
if (
msg.get('type') != 'message'
and msg.get('channel').decode() != events_redis_topic
):
continue
try:
evt = Event.build(msg.get('data').decode())
except Exception as e:
logger.warning('Error parsing event: %s: %s', msg.get('data'), e)
continue
sock.send(str(evt))
except ConnectionClosed as e:
logger.info(
'Websocket connection to %s closed, reason=%s, message=%s',
ws_key,
e.reason,
e.message,
)
finally:
sub.unsubscribe(events_redis_topic)
return ''
# vim:sw=4:ts=4:et:

View file

@ -1,3 +1,75 @@
from platypush.config import Config
from logging import getLogger
from threading import Thread
from typing_extensions import override
events_redis_topic = f'__platypush/{Config.get("device_id")}/events' # type: ignore
from redis import ConnectionError
from tornado.ioloop import IOLoop
from tornado.websocket import WebSocketHandler
from platypush.config import Config
from platypush.message.event import Event
from platypush.utils import get_redis
events_redis_topic = f'_platypush/{Config.get("device_id")}/events' # type: ignore
logger = getLogger(__name__)
class WSEventProxy(WebSocketHandler, Thread):
"""
Websocket event proxy mapped to ``/ws/events``.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._sub = get_redis().pubsub()
self._io_loop = IOLoop.current()
@override
def open(self, *_, **__):
logger.info('Started websocket connection with %s', self.request.remote_ip)
self.name = f'ws:events@{self.request.remote_ip}'
self.start()
@override
def on_message(self, *_, **__):
pass
@override
def data_received(self, *_, **__):
pass
@override
def run(self) -> None:
super().run()
self._sub.subscribe(events_redis_topic)
try:
for msg in self._sub.listen():
if (
msg.get('type') != 'message'
and msg.get('channel').decode() != events_redis_topic
):
continue
try:
evt = Event.build(msg.get('data').decode())
except Exception as e:
logger.warning('Error parsing event: %s: %s', msg.get('data'), e)
continue
self._io_loop.asyncio_loop.call_soon_threadsafe( # type: ignore
self.write_message, str(evt)
)
except ConnectionError:
pass
@override
def on_close(self):
self._sub.unsubscribe(events_redis_topic)
self._sub.close()
logger.info(
'Websocket connection to %s closed, reason=%s, message=%s',
self.request.remote_ip,
self.close_code,
self.close_reason,
)

View file

@ -1,28 +0,0 @@
from typing import Any, Dict
from gunicorn.app.wsgiapp import WSGIApplication
from uvicorn.workers import UvicornWorker as BaseUvicornWorker
class UvicornWorker(BaseUvicornWorker):
CONFIG_KWARGS: Dict[str, Any] = {"loop": "auto", "http": "auto", "lifespan": "on"}
class WSGIApplicationWrapper(WSGIApplication):
"""
Wrapper for the Flask application into a WSGI application.
"""
def __init__(self, app_uri, options=None):
self.options = options or {}
self.app_uri = app_uri
super().__init__()
def load_config(self):
config = {
key: value
for key, value in self.options.items()
if key in self.cfg.settings and value is not None # type: ignore
}
for key, value in config.items():
self.cfg.set(key.lower(), value) # type: ignore

View file

@ -2,8 +2,12 @@ import base64
import json
from platypush.backend import Backend
from platypush.message.event.nfc import NFCTagDetectedEvent, NFCTagRemovedEvent, NFCDeviceConnectedEvent, \
NFCDeviceDisconnectedEvent
from platypush.message.event.nfc import (
NFCTagDetectedEvent,
NFCTagRemovedEvent,
NFCDeviceConnectedEvent,
NFCDeviceDisconnectedEvent,
)
class NfcBackend(Backend):
@ -20,7 +24,7 @@ class NfcBackend(Backend):
Requires:
* **nfcpy** >= 1.0 (``pip install 'nfcpy>=1.0'``)
* **ndef** (``pip install ndef``)
* **ndef** (``pip install ndeflib``)
Run the following to check if your device is compatible with nfcpy and the right permissions are set::
@ -49,7 +53,11 @@ class NfcBackend(Backend):
self._clf = nfc.ContactlessFrontend()
self._clf.open(self.device_id)
self.bus.post(NFCDeviceConnectedEvent(reader=self._get_device_str()))
self.logger.info('Initialized NFC reader backend on device {}'.format(self._get_device_str()))
self.logger.info(
'Initialized NFC reader backend on device {}'.format(
self._get_device_str()
)
)
return self._clf
@ -107,51 +115,92 @@ class NfcBackend(Backend):
r = {
**r,
'type': 'smartposter',
**{attr: getattr(record, attr) for attr in ['resource', 'titles', 'title', 'action', 'icon',
'icons', 'resource_size', 'resource_type']},
**{
attr: getattr(record, attr)
for attr in [
'resource',
'titles',
'title',
'action',
'icon',
'icons',
'resource_size',
'resource_type',
]
},
}
elif isinstance(record, DeviceInformationRecord):
r = {
**r,
'type': 'device_info',
**{attr: getattr(record, attr) for attr in ['vendor_name', 'model_name', 'unique_name',
'uuid_string', 'version_string']},
**{
attr: getattr(record, attr)
for attr in [
'vendor_name',
'model_name',
'unique_name',
'uuid_string',
'version_string',
]
},
}
elif isinstance(record, WifiSimpleConfigRecord):
r = {
**r,
'type': 'wifi_simple_config',
**{attr: record[attr] for attr in record.attribute_names()}
**{attr: record[attr] for attr in record.attribute_names()},
}
elif isinstance(record, WifiPeerToPeerRecord):
r = {
**r,
'type': 'wifi_peer_to_peer',
**{attr: record[attr] for attr in record.attribute_names()}
**{attr: record[attr] for attr in record.attribute_names()},
}
elif isinstance(record, BluetoothEasyPairingRecord):
r = {
**r,
'type': 'bluetooth_easy_pairing',
**{attr: getattr(record, attr) for attr in ['device_address', 'device_name', 'device_class']},
**{
attr: getattr(record, attr)
for attr in ['device_address', 'device_name', 'device_class']
},
}
elif isinstance(record, BluetoothLowEnergyRecord):
r = {
**r,
'type': 'bluetooth_low_energy',
**{attr: getattr(record, attr) for attr in ['device_address', 'device_name', 'role_capabilities',
'appearance', 'flags', 'security_manager_tk_value',
**{
attr: getattr(record, attr)
for attr in [
'device_address',
'device_name',
'role_capabilities',
'appearance',
'flags',
'security_manager_tk_value',
'secure_connections_confirmation_value',
'secure_connections_random_value']},
'secure_connections_random_value',
]
},
}
elif isinstance(record, SignatureRecord):
r = {
**r,
'type': 'signature',
**{attr: getattr(record, attr) for attr in ['version', 'signature_type', 'hash_type', 'signature',
'signature_uri', 'certificate_format',
'certificate_store', 'certificate_uri',
'secure_connections_random_value']},
**{
attr: getattr(record, attr)
for attr in [
'version',
'signature_type',
'hash_type',
'signature',
'signature_uri',
'certificate_format',
'certificate_store',
'certificate_uri',
'secure_connections_random_value',
]
},
}
else:
r = {
@ -175,7 +224,11 @@ class NfcBackend(Backend):
tag_id = self._parse_id(tag)
records = self._parse_records(tag)
self.bus.post(NFCTagDetectedEvent(reader=self._get_device_str(), tag_id=tag_id, records=records))
self.bus.post(
NFCTagDetectedEvent(
reader=self._get_device_str(), tag_id=tag_id, records=records
)
)
return True
return callback
@ -183,7 +236,9 @@ class NfcBackend(Backend):
def _on_release(self):
def callback(tag):
tag_id = self._parse_id(tag)
self.bus.post(NFCTagRemovedEvent(reader=self._get_device_str(), tag_id=tag_id))
self.bus.post(
NFCTagRemovedEvent(reader=self._get_device_str(), tag_id=tag_id)
)
return callback
@ -193,10 +248,12 @@ class NfcBackend(Backend):
while not self.should_stop():
try:
clf = self._get_clf()
clf.connect(rdwr={
clf.connect(
rdwr={
'on-connect': self._on_connect(),
'on-release': self._on_release(),
})
}
)
finally:
self.close()

View file

@ -8,6 +8,7 @@ manifest:
platypush.message.event.nfc.NFCTagRemovedEvent: when an NFC tag is removed
install:
pip:
- ndef
- nfcpy>=1.0
- ndeflib
package: platypush.backend.nfc
type: backend

View file

@ -3,6 +3,7 @@ manifest:
platypush.message.event.weather.NewWeatherConditionEvent: when there is a weather
condition update
install:
pip: []
pip:
- buienradar
package: platypush.backend.weather.buienradar
type: backend

View file

@ -7,9 +7,13 @@ import os
from typing import Optional, Dict, Any
from platypush.context import get_bus, get_plugin
from platypush.message.event.assistant import ConversationStartEvent, \
ConversationEndEvent, SpeechRecognizedEvent, VolumeChangedEvent, \
ResponseEvent
from platypush.message.event.assistant import (
ConversationStartEvent,
ConversationEndEvent,
SpeechRecognizedEvent,
VolumeChangedEvent,
ResponseEvent,
)
from platypush.message.event.google import GoogleDeviceOnOffEvent
@ -34,29 +38,42 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin):
* **tenacity** (``pip install tenacity``)
* **google-assistant-sdk** (``pip install google-assistant-sdk[samples]``)
* **google-auth** (``pip install google-auth``)
"""
api_endpoint = 'embeddedassistant.googleapis.com'
grpc_deadline = 60 * 3 + 5
device_handler = None
_default_credentials_file = os.path.join(
os.path.expanduser('~'),
'.config',
'google-oauthlib-tool',
'credentials.json',
)
def __init__(self,
credentials_file=os.path.join(
os.path.expanduser('~'), '.config',
'google-oauthlib-tool', 'credentials.json'),
device_config=os.path.join(
os.path.expanduser('~'), '.config', 'googlesamples-assistant',
'device_config.json'),
_default_device_config = os.path.join(
os.path.expanduser('~'),
'.config',
'googlesamples-assistant',
'device_config.json',
)
def __init__(
self,
credentials_file=_default_credentials_file,
device_config=_default_device_config,
language='en-US',
play_response=True,
tts_plugin=None,
tts_args=None,
**kwargs):
**kwargs
):
"""
:param credentials_file: Path to the Google OAuth credentials file
(default: ~/.config/google-oauthlib-tool/credentials.json).
See https://developers.google.com/assistant/sdk/guides/library/python/embed/install-sample#generate_credentials
See
https://developers.google.com/assistant/sdk/guides/library/python/embed/install-sample#generate_credentials
for instructions to get your own credentials file.
:type credentials_file: str
@ -81,6 +98,7 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin):
"""
import googlesamples.assistant.grpc.audio_helpers as audio_helpers
super().__init__(**kwargs)
self.audio_sample_rate = audio_helpers.DEFAULT_AUDIO_SAMPLE_RATE
@ -114,8 +132,9 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin):
self.credentials.refresh(self.http_request)
except Exception as ex:
self.logger.error('Error loading credentials: %s', str(ex))
self.logger.error('Run google-oauthlib-tool to initialize '
'new OAuth 2.0 credentials.')
self.logger.error(
'Run google-oauthlib-tool to initialize ' 'new OAuth 2.0 credentials.'
)
raise
self.grpc_channel = None
@ -128,27 +147,25 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin):
self.interactions = []
# Create an authorized gRPC channel.
self.grpc_channel = secure_authorized_channel(self.credentials, self.http_request, self.api_endpoint)
self.grpc_channel = secure_authorized_channel(
self.credentials, self.http_request, self.api_endpoint
)
self.logger.info('Connecting to {}'.format(self.api_endpoint))
# Configure audio source and sink.
audio_device = None
audio_source = audio_device = (
audio_device or audio_helpers.SoundDeviceStream(
audio_source = audio_device = audio_device or audio_helpers.SoundDeviceStream(
sample_rate=self.audio_sample_rate,
sample_width=self.audio_sample_width,
block_size=self.audio_block_size,
flush_size=self.audio_flush_size
)
flush_size=self.audio_flush_size,
)
audio_sink = (
audio_device or audio_helpers.SoundDeviceStream(
audio_sink = audio_device or audio_helpers.SoundDeviceStream(
sample_rate=self.audio_sample_rate,
sample_width=self.audio_sample_width,
block_size=self.audio_block_size,
flush_size=self.audio_flush_size
)
flush_size=self.audio_flush_size,
)
# Create conversation stream with the given audio source and sink.
@ -162,21 +179,28 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin):
self._install_device_handlers()
def on_conversation_start(self):
""" Conversation start handler """
"""Conversation start handler"""
def handler():
get_bus().post(ConversationStartEvent(assistant=self))
return handler
def on_conversation_end(self):
""" Conversation end handler """
"""Conversation end handler"""
def handler(with_follow_on_turn):
get_bus().post(ConversationEndEvent(assistant=self, with_follow_on_turn=with_follow_on_turn))
get_bus().post(
ConversationEndEvent(
assistant=self, with_follow_on_turn=with_follow_on_turn
)
)
return handler
def on_speech_recognized(self):
""" Speech recognized handler """
"""Speech recognized handler"""
def handler(phrase):
get_bus().post(SpeechRecognizedEvent(assistant=self, phrase=phrase))
self.interactions.append({'request': phrase})
@ -184,14 +208,16 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin):
return handler
def on_volume_changed(self):
""" Volume changed event """
"""Volume changed event"""
def handler(volume):
get_bus().post(VolumeChangedEvent(assistant=self, volume=volume))
return handler
def on_response(self):
""" Response handler """
"""Response handler"""
def handler(response):
get_bus().post(ResponseEvent(assistant=self, response_text=response))
@ -207,8 +233,14 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin):
return handler
@action
def start_conversation(self, *args, language: Optional[str] = None, tts_plugin: Optional[str] = None,
tts_args: Optional[Dict[str, Any]] = None, **kwargs):
def start_conversation(
self,
*_,
language: Optional[str] = None,
tts_plugin: Optional[str] = None,
tts_args: Optional[Dict[str, Any]] = None,
**__
):
"""
Start a conversation
@ -242,7 +274,8 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin):
self._init_assistant()
self.on_conversation_start()
with SampleAssistant(language_code=language,
with SampleAssistant(
language_code=language,
device_model_id=self.device_model_id,
device_id=self.device_id,
conversation_stream=self.conversation_stream,
@ -255,14 +288,17 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin):
on_conversation_end=self.on_conversation_end(),
on_volume_changed=self.on_volume_changed(),
on_response=self.on_response(),
on_speech_recognized=self.on_speech_recognized()) as self.assistant:
on_speech_recognized=self.on_speech_recognized(),
) as self.assistant:
continue_conversation = True
while continue_conversation:
try:
continue_conversation = self.assistant.assist()
except Exception as e:
self.logger.warning('Unhandled assistant exception: {}'.format(str(e)))
self.logger.warning(
'Unhandled assistant exception: {}'.format(str(e))
)
self.logger.exception(e)
self._init_assistant()
@ -297,14 +333,18 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin):
def _install_device_handlers(self):
import googlesamples.assistant.grpc.device_helpers as device_helpers
self.device_handler = device_helpers.DeviceRequestHandler(self.device_id)
@self.device_handler.command('action.devices.commands.OnOff')
def handler(on):
get_bus().post(GoogleDeviceOnOffEvent(
def handler(on): # type: ignore
get_bus().post(
GoogleDeviceOnOffEvent(
device_id=self.device_id,
device_model_id=self.device_model_id,
on=on))
on=on,
)
)
# vim:sw=4:ts=4:et:

View file

@ -10,5 +10,6 @@ manifest:
pip:
- tenacity
- google-assistant-sdk
- google-auth
package: platypush.plugins.assistant.google.pushtotalk
type: plugin

View file

@ -15,6 +15,7 @@ class CameraGstreamerPlugin(CameraPlugin):
Requires:
* **gst-python**
* **pygobject**
On Debian and derived systems:
@ -39,15 +40,24 @@ class CameraGstreamerPlugin(CameraPlugin):
pipeline = Pipeline()
src = pipeline.add_source('v4l2src', device=camera.info.device)
convert = pipeline.add('videoconvert')
assert camera.info and camera.info.resolution
video_filter = pipeline.add(
'capsfilter', caps='video/x-raw,format=RGB,width={width},height={height},framerate={fps}/1'.format(
width=camera.info.resolution[0], height=camera.info.resolution[1], fps=camera.info.fps))
'capsfilter',
caps='video/x-raw,format=RGB,width={width},height={height},framerate={fps}/1'.format(
width=camera.info.resolution[0],
height=camera.info.resolution[1],
fps=camera.info.fps,
),
)
sink = pipeline.add_sink('appsink', name='appsink', sync=False)
pipeline.link(src, convert, video_filter, sink)
return pipeline
def start_camera(self, camera: GStreamerCamera, preview: bool = False, *args, **kwargs):
def start_camera(
self, camera: GStreamerCamera, preview: bool = False, *args, **kwargs
):
super().start_camera(*args, camera=camera, preview=preview, **kwargs)
if camera.object:
camera.object.play()
@ -56,16 +66,27 @@ class CameraGstreamerPlugin(CameraPlugin):
if camera.object:
camera.object.stop()
def capture_frame(self, camera: GStreamerCamera, *args, **kwargs) -> Optional[ImageType]:
timed_out = not camera.object.data_ready.wait(timeout=5 + (1. / camera.info.fps))
def capture_frame(self, camera: GStreamerCamera, *_, **__) -> Optional[ImageType]:
if not (camera.info and camera.info.fps and camera.info.resolution):
return None
timed_out = not camera.object.data_ready.wait(
timeout=5 + (1.0 / camera.info.fps)
)
if timed_out:
self.logger.warning('Frame capture timeout')
return
return None
data = camera.object.data
if data is None:
return None
camera.object.data_ready.clear()
if not data and len(data) != camera.info.resolution[0] * camera.info.resolution[1] * 3:
return
if (
not data
and len(data) != camera.info.resolution[0] * camera.info.resolution[1] * 3
):
return None
return Image.frombytes('RGB', camera.info.resolution, data)

View file

@ -4,6 +4,7 @@ manifest:
pip:
- numpy
- Pillow
- pygobject
apt:
- python3-gi
- python3-gst-1.0

View file

@ -3,6 +3,7 @@ manifest:
install:
pip:
- google-api-python-client
- google-auth
- oauth2client
package: platypush.plugins.google.calendar
type: plugin

View file

@ -3,6 +3,7 @@ manifest:
install:
pip:
- google-api-python-client
- google-auth
- oauth2client
package: platypush.plugins.google.drive
type: plugin

View file

@ -3,6 +3,7 @@ manifest:
install:
pip:
- google-api-python-client
- google-auth
- oauth2client
package: platypush.plugins.google.fit
type: plugin

View file

@ -3,6 +3,7 @@ manifest:
install:
pip:
- google-api-python-client
- google-auth
- oauth2client
package: platypush.plugins.google.mail
type: plugin

View file

@ -3,6 +3,7 @@ manifest:
install:
pip:
- google-api-python-client
- google-auth
- oauth2client
package: platypush.plugins.google.maps
type: plugin

View file

@ -3,6 +3,7 @@ manifest:
install:
pip:
- google-api-python-client
- google-auth
- oauth2client
- google-cloud-pubsub
package: platypush.plugins.google.pubsub

View file

@ -3,6 +3,7 @@ manifest:
install:
pip:
- google-api-python-client
- google-auth
- oauth2client
- google-cloud-translate
package: platypush.plugins.google.translate

View file

@ -3,6 +3,7 @@ manifest:
install:
pip:
- google-api-python-client
- google-auth
- oauth2client
package: platypush.plugins.google.youtube
type: plugin

View file

@ -30,7 +30,7 @@ class MediaPlugin(Plugin, ABC):
Requires:
* A media player installed (supported so far: mplayer, vlc, mpv, omxplayer, chromecast)
* **python-libtorrent-bin** (``pip install python-libtorrent-bin``), optional, for torrent support over native
* **python-libtorrent** (``pip install python-libtorrent``), optional, for torrent support over native
library
* *rtorrent* installed - optional, for torrent support over rtorrent
* **youtube-dl** installed on your system (see your distro instructions), optional for YouTube support
@ -43,41 +43,123 @@ class MediaPlugin(Plugin, ABC):
# A media plugin can either be local or remote (e.g. control media on
# another device)
_is_local = True
_NOT_IMPLEMENTED_ERR = NotImplementedError('This method must be implemented in a derived class')
_NOT_IMPLEMENTED_ERR = NotImplementedError(
'This method must be implemented in a derived class'
)
# Supported audio extensions
audio_extensions = {
'3gp', 'aa', 'aac', 'aax', 'act', 'aiff', 'amr', 'ape', 'au',
'awb', 'dct', 'dss', 'dvf', 'flac', 'gsm', 'iklax', 'ivs',
'm4a', 'm4b', 'm4p', 'mmf', 'mp3', 'mpc', 'msv', 'nmf', 'nsf',
'ogg,', 'opus', 'ra,', 'raw', 'sln', 'tta', 'vox', 'wav',
'wma', 'wv', 'webm', '8svx',
'3gp',
'aa',
'aac',
'aax',
'act',
'aiff',
'amr',
'ape',
'au',
'awb',
'dct',
'dss',
'dvf',
'flac',
'gsm',
'iklax',
'ivs',
'm4a',
'm4b',
'm4p',
'mmf',
'mp3',
'mpc',
'msv',
'nmf',
'nsf',
'ogg,',
'opus',
'ra,',
'raw',
'sln',
'tta',
'vox',
'wav',
'wma',
'wv',
'webm',
'8svx',
}
# Supported video extensions
video_extensions = {
'webm', 'mkv', 'flv', 'flv', 'vob', 'ogv', 'ogg', 'drc', 'gif',
'gifv', 'mng', 'avi', 'mts', 'm2ts', 'mov', 'qt', 'wmv', 'yuv',
'rm', 'rmvb', 'asf', 'amv', 'mp4', 'm4p', 'm4v', 'mpg', 'mp2',
'mpeg', 'mpe', 'mpv', 'mpg', 'mpeg', 'm2v', 'm4v', 'svi',
'3gp', '3g2', 'mxf', 'roq', 'nsv', 'flv', 'f4v', 'f4p', 'f4a',
'webm',
'mkv',
'flv',
'flv',
'vob',
'ogv',
'ogg',
'drc',
'gif',
'gifv',
'mng',
'avi',
'mts',
'm2ts',
'mov',
'qt',
'wmv',
'yuv',
'rm',
'rmvb',
'asf',
'amv',
'mp4',
'm4p',
'm4v',
'mpg',
'mp2',
'mpeg',
'mpe',
'mpv',
'mpg',
'mpeg',
'm2v',
'm4v',
'svi',
'3gp',
'3g2',
'mxf',
'roq',
'nsv',
'flv',
'f4v',
'f4p',
'f4a',
'f4b',
}
_supported_media_plugins = {'media.mplayer', 'media.omxplayer', 'media.mpv',
'media.vlc', 'media.chromecast', 'media.gstreamer'}
_supported_media_plugins = {
'media.mplayer',
'media.omxplayer',
'media.mpv',
'media.vlc',
'media.chromecast',
'media.gstreamer',
}
_supported_media_types = ['file', 'jellyfin', 'plex', 'torrent', 'youtube']
_default_search_timeout = 60 # 60 seconds
def __init__(self,
def __init__(
self,
media_dirs: Optional[List[str]] = None,
download_dir: Optional[str] = None,
env: Optional[Dict[str, str]] = None,
volume: Optional[Union[float, int]] = None,
torrent_plugin: str = 'torrent',
youtube_format: str = 'best',
*args, **kwargs):
**kwargs,
):
"""
:param media_dirs: Directories that will be scanned for media files when
a search is performed (default: none)
@ -134,17 +216,21 @@ class MediaPlugin(Plugin, ABC):
self._env = env or {}
self.media_dirs = set(
filter(
lambda _: os.path.isdir(_),
map(
lambda _: os.path.abspath(os.path.expanduser(_)),
media_dirs
)
os.path.isdir,
[os.path.abspath(os.path.expanduser(d)) for d in media_dirs],
)
)
self.download_dir = os.path.abspath(os.path.expanduser(
download_dir or player_config.get('download_dir') or
os.path.join((os.path.expanduser('~') or self._env.get('HOME') or '/'), 'Downloads')))
self.download_dir = os.path.abspath(
os.path.expanduser(
download_dir
or player_config.get('download_dir')
or os.path.join(
(os.path.expanduser('~') or self._env.get('HOME') or '/'),
'Downloads',
)
)
)
if not os.path.isdir(self.download_dir):
os.makedirs(self.download_dir, exist_ok=True)
@ -162,14 +248,17 @@ class MediaPlugin(Plugin, ABC):
# More than 5% of the torrent has been downloaded
if event.args.get('progress', 0) > 5 and event.args.get('files'):
evt_queue.put(event.args['files'])
return handler
@staticmethod
def _is_youtube_resource(resource):
return resource.startswith('youtube:') \
or resource.startswith('https://youtu.be/') \
or resource.startswith('https://www.youtube.com/watch?v=') \
return (
resource.startswith('youtube:')
or resource.startswith('https://youtu.be/')
or resource.startswith('https://www.youtube.com/watch?v=')
or resource.startswith('https://youtube.com/watch?v=')
)
def _get_resource(self, resource):
"""
@ -194,15 +283,21 @@ class MediaPlugin(Plugin, ABC):
resource = self.get_youtube_video_url(resource)
elif resource.startswith('magnet:?'):
self.logger.info('Downloading torrent {} to {}'.format(
resource, self.download_dir))
self.logger.info(
'Downloading torrent {} to {}'.format(resource, self.download_dir)
)
torrents = get_plugin(self.torrent_plugin)
evt_queue = queue.Queue()
torrents.download(resource, download_dir=self.download_dir, _async=True, is_media=True,
event_hndl=self._torrent_event_handler(evt_queue))
torrents.download(
resource,
download_dir=self.download_dir,
_async=True,
is_media=True,
event_hndl=self._torrent_event_handler(evt_queue),
)
resources = [f for f in evt_queue.get()]
resources = [f for f in evt_queue.get()] # noqa: C416
if resources:
self._videos_queue = sorted(resources)
@ -265,7 +360,7 @@ class MediaPlugin(Plugin, ABC):
@action
def next(self):
""" Play the next item in the queue """
"""Play the next item in the queue"""
self.stop()
if self._videos_queue:
@ -323,8 +418,14 @@ class MediaPlugin(Plugin, ABC):
raise self._NOT_IMPLEMENTED_ERR
@action
def search(self, query, types=None, queue_results=False, autoplay=False,
search_timeout=_default_search_timeout):
def search(
self,
query,
types=None,
queue_results=False,
autoplay=False,
search_timeout=_default_search_timeout,
):
"""
Perform a video search.
@ -356,8 +457,12 @@ class MediaPlugin(Plugin, ABC):
results_queues[media_type] = queue.Queue()
search_hndl = self._get_search_handler_by_type(media_type)
worker_threads[media_type] = threading.Thread(
target=self._search_worker(query=query, search_hndl=search_hndl,
results_queue=results_queues[media_type]))
target=self._search_worker(
query=query,
search_hndl=search_hndl,
results_queue=results_queues[media_type],
)
)
worker_threads[media_type].start()
for media_type in types:
@ -368,11 +473,15 @@ class MediaPlugin(Plugin, ABC):
results[media_type].extend(items)
except queue.Empty:
self.logger.warning('Search for "{}" media type {} timed out'.
format(query, media_type))
self.logger.warning(
'Search for "{}" media type {} timed out'.format(query, media_type)
)
except Exception as e:
self.logger.warning('Error while searching for "{}", media type {}'.
format(query, media_type))
self.logger.warning(
'Error while searching for "{}", media type {}'.format(
query, media_type
)
)
self.logger.exception(e)
flattened_results = []
@ -402,23 +511,29 @@ class MediaPlugin(Plugin, ABC):
results_queue.put(search_hndl.search(query))
except Exception as e:
results_queue.put(e)
return thread
def _get_search_handler_by_type(self, search_type):
if search_type == 'file':
from .search import LocalMediaSearcher
return LocalMediaSearcher(self.media_dirs, media_plugin=self)
if search_type == 'torrent':
from .search import TorrentMediaSearcher
return TorrentMediaSearcher(media_plugin=self)
if search_type == 'youtube':
from .search import YoutubeMediaSearcher
return YoutubeMediaSearcher(media_plugin=self)
if search_type == 'plex':
from .search import PlexMediaSearcher
return PlexMediaSearcher(media_plugin=self)
if search_type == 'jellyfin':
from .search import JellyfinMediaSearcher
return JellyfinMediaSearcher(media_plugin=self)
self.logger.warning('Unsupported search type: {}'.format(search_type))
@ -463,18 +578,23 @@ class MediaPlugin(Plugin, ABC):
http = get_backend('http')
if not http:
self.logger.warning('Unable to stream {}: HTTP backend unavailable'.
format(media))
self.logger.warning(
'Unable to stream {}: HTTP backend unavailable'.format(media)
)
return
self.logger.info('Starting streaming {}'.format(media))
response = requests.put('{url}/media{download}'.format(
url=http.local_base_url, download='?download' if download else ''),
json={'source': media, 'subtitles': subtitles})
response = requests.put(
'{url}/media{download}'.format(
url=http.local_base_url, download='?download' if download else ''
),
json={'source': media, 'subtitles': subtitles},
)
if not response.ok:
self.logger.warning('Unable to start streaming: {}'.
format(response.text or response.reason))
self.logger.warning(
'Unable to start streaming: {}'.format(response.text or response.reason)
)
return None, (response.text or response.reason)
return response.json()
@ -483,16 +603,19 @@ class MediaPlugin(Plugin, ABC):
def stop_streaming(self, media_id):
http = get_backend('http')
if not http:
self.logger.warning('Cannot unregister {}: HTTP backend unavailable'.
format(media_id))
self.logger.warning(
'Cannot unregister {}: HTTP backend unavailable'.format(media_id)
)
return
response = requests.delete('{url}/media/{id}'.
format(url=http.local_base_url, id=media_id))
response = requests.delete(
'{url}/media/{id}'.format(url=http.local_base_url, id=media_id)
)
if not response.ok:
self.logger.warning('Unable to unregister media_id {}: {}'.format(
media_id, response.reason))
self.logger.warning(
'Unable to unregister media_id {}: {}'.format(media_id, response.reason)
)
return
return response.json()
@ -511,11 +634,18 @@ class MediaPlugin(Plugin, ABC):
@staticmethod
def _youtube_search_html_parse(query):
from .search import YoutubeMediaSearcher
# noinspection PyProtectedMember
return YoutubeMediaSearcher()._youtube_search_html_parse(query)
def get_youtube_video_url(self, url, youtube_format: Optional[str] = None):
ytdl_cmd = ['youtube-dl', '-f', youtube_format or self.youtube_format, '-g', url]
ytdl_cmd = [
'youtube-dl',
'-f',
youtube_format or self.youtube_format,
'-g',
url,
]
self.logger.info(f'Executing command {" ".join(ytdl_cmd)}')
youtube_dl = subprocess.Popen(ytdl_cmd, stdout=subprocess.PIPE)
url = youtube_dl.communicate()[0].decode().strip()
@ -564,15 +694,29 @@ class MediaPlugin(Plugin, ABC):
if filename.startswith('file://'):
filename = filename[7:]
result = subprocess.Popen(["ffprobe", filename], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
result = subprocess.Popen(
["ffprobe", filename], stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
return functools.reduce(
lambda t, t_i: t + t_i,
[float(t) * pow(60, i) for (i, t) in enumerate(re.search(
r'^Duration:\s*([^,]+)', [x.decode()
[
float(t) * pow(60, i)
for (i, t) in enumerate(
re.search(
r'^Duration:\s*([^,]+)',
[
x.decode()
for x in result.stdout.readlines()
if "Duration" in x.decode()].pop().strip()
).group(1).split(':')[::-1])]
if "Duration" in x.decode()
]
.pop()
.strip(),
)
.group(1)
.split(':')[::-1]
)
],
)
@action
@ -608,13 +752,14 @@ class MediaPlugin(Plugin, ABC):
return
if subtitles.startswith('file://'):
subtitles = subtitles[len('file://'):]
subtitles = subtitles[len('file://') :]
if os.path.isfile(subtitles):
return os.path.abspath(subtitles)
else:
content = requests.get(subtitles).content
f = tempfile.NamedTemporaryFile(prefix='media_subs_',
suffix='.srt', delete=False)
f = tempfile.NamedTemporaryFile(
prefix='media_subs_', suffix='.srt', delete=False
)
with f:
f.write(content)

View file

@ -15,6 +15,7 @@ class MediaGstreamerPlugin(MediaPlugin):
Requires:
* **gst-python**
* **pygobject**
On Debian and derived systems:
@ -46,7 +47,7 @@ class MediaGstreamerPlugin(MediaPlugin):
return pipeline
@action
def play(self, resource: Optional[str] = None, **args):
def play(self, resource: Optional[str] = None, **_):
"""
Play a resource.
@ -67,20 +68,20 @@ class MediaGstreamerPlugin(MediaPlugin):
pipeline = self._allocate_pipeline(resource)
pipeline.play()
if self.volume:
pipeline.set_volume(self.volume / 100.)
pipeline.set_volume(self.volume / 100.0)
return self.status()
@action
def pause(self):
""" Toggle the paused state """
"""Toggle the paused state"""
assert self._player, 'No instance is running'
self._player.pause()
return self.status()
@action
def quit(self):
""" Stop and quit the player (alias for :meth:`.stop`) """
"""Stop and quit the player (alias for :meth:`.stop`)"""
self._stop_torrent()
assert self._player, 'No instance is running'
@ -90,18 +91,18 @@ class MediaGstreamerPlugin(MediaPlugin):
@action
def stop(self):
""" Stop and quit the player (alias for :meth:`.quit`) """
"""Stop and quit the player (alias for :meth:`.quit`)"""
return self.quit()
@action
def voldown(self, step=10.0):
""" Volume down by (default: 10)% """
"""Volume down by (default: 10)%"""
# noinspection PyUnresolvedReferences
return self.set_volume(self.get_volume().output - step)
@action
def volup(self, step=10.0):
""" Volume up by (default: 10)% """
"""Volume up by (default: 10)%"""
# noinspection PyUnresolvedReferences
return self.set_volume(self.get_volume().output + step)
@ -113,7 +114,7 @@ class MediaGstreamerPlugin(MediaPlugin):
:return: Volume value between 0 and 100.
"""
assert self._player, 'No instance is running'
return self._player.get_volume() * 100.
return self._player.get_volume() * 100.0
@action
def set_volume(self, volume):
@ -124,7 +125,7 @@ class MediaGstreamerPlugin(MediaPlugin):
"""
assert self._player, 'Player not running'
# noinspection PyTypeChecker
volume = max(0, min(1, volume / 100.))
volume = max(0, min(1, volume / 100.0))
self._player.set_volume(volume)
MediaPipeline.post_event(MediaVolumeChangedEvent, volume=volume * 100)
return self.status()
@ -142,12 +143,12 @@ class MediaGstreamerPlugin(MediaPlugin):
@action
def back(self, offset=60.0):
""" Back by (default: 60) seconds """
"""Back by (default: 60) seconds"""
return self.seek(-offset)
@action
def forward(self, offset=60.0):
""" Forward by (default: 60) seconds """
"""Forward by (default: 60) seconds"""
return self.seek(offset)
@action
@ -158,7 +159,7 @@ class MediaGstreamerPlugin(MediaPlugin):
return self._player and self._player.is_playing()
@action
def load(self, resource, **args):
def load(self, resource, **_):
"""
Load/queue a resource/video to the player (alias for :meth:`.play`).
"""
@ -166,7 +167,7 @@ class MediaGstreamerPlugin(MediaPlugin):
@action
def mute(self):
""" Toggle mute state """
"""Toggle mute state"""
assert self._player, 'No instance is running'
muted = self._player.is_muted()
if muted:
@ -201,11 +202,15 @@ class MediaGstreamerPlugin(MediaPlugin):
return {
'duration': length,
'filename': self._resource[7:] if self._resource.startswith('file://') else self._resource,
'filename': self._resource[7:]
if self._resource.startswith('file://')
else self._resource,
'mute': self._player.is_muted(),
'name': self._resource,
'pause': self._player.is_paused(),
'percent_pos': pos / length if pos is not None and length is not None and pos >= 0 and length > 0 else 0,
'percent_pos': pos / length
if pos is not None and length is not None and pos >= 0 and length > 0
else 0,
'position': pos,
'seekable': length is not None and length > 0,
'state': self._gst_to_player_state(self._player.get_state()).value,
@ -217,6 +222,7 @@ class MediaGstreamerPlugin(MediaPlugin):
def _gst_to_player_state(state) -> PlayerState:
# noinspection PyUnresolvedReferences,PyPackageRequirements
from gi.repository import Gst
if state == Gst.State.READY:
return PlayerState.STOP
if state == Gst.State.PAUSED:
@ -225,13 +231,13 @@ class MediaGstreamerPlugin(MediaPlugin):
return PlayerState.PLAY
return PlayerState.IDLE
def toggle_subtitles(self, *args, **kwargs):
def toggle_subtitles(self, *_, **__):
raise NotImplementedError
def set_subtitles(self, filename, *args, **kwargs):
def set_subtitles(self, *_, **__):
raise NotImplementedError
def remove_subtitles(self, *args, **kwargs):
def remove_subtitles(self, *_, **__):
raise NotImplementedError

View file

@ -4,6 +4,8 @@ manifest:
apt:
- python3-gi
- python3-gst-1.0
pip:
- pygobject
pacman:
- gst-python
- python-gobject

View file

@ -3,5 +3,7 @@ manifest:
install:
pip:
- pycups
apt:
- libcups2-dev
package: platypush.plugins.printer.cups
type: plugin

View file

@ -19,6 +19,7 @@ class SensorLtr559Plugin(SensorPlugin):
Requires:
* ``ltr559`` (``pip install ltr559``)
* ``smbus`` (``pip install smbus``)
Triggers:

View file

@ -6,5 +6,6 @@ manifest:
install:
pip:
- ltr559
- smbus
package: platypush.plugins.sensor.ltr559
type: plugin

View file

@ -9,10 +9,17 @@ import time
from platypush.context import get_bus
from platypush.plugins import Plugin, action
from platypush.message.event.torrent import \
TorrentDownloadStartEvent, TorrentDownloadedMetadataEvent, TorrentStateChangeEvent, \
TorrentDownloadProgressEvent, TorrentDownloadCompletedEvent, TorrentDownloadStopEvent, \
TorrentPausedEvent, TorrentResumedEvent, TorrentQueuedEvent
from platypush.message.event.torrent import (
TorrentDownloadStartEvent,
TorrentDownloadedMetadataEvent,
TorrentStateChangeEvent,
TorrentDownloadProgressEvent,
TorrentDownloadCompletedEvent,
TorrentDownloadStopEvent,
TorrentPausedEvent,
TorrentResumedEvent,
TorrentQueuedEvent,
)
class TorrentPlugin(Plugin):
@ -21,7 +28,7 @@ class TorrentPlugin(Plugin):
Requires:
* **python-libtorrent-bin** (``pip install python-libtorrent-bin``)
* **python-libtorrent** (``pip install python-libtorrent``)
"""
@ -39,8 +46,14 @@ class TorrentPlugin(Plugin):
# noinspection HttpUrlsUsage
default_popcorn_base_url = 'http://popcorn-time.ga'
def __init__(self, download_dir=None, torrent_ports=None, imdb_key=None, popcorn_base_url=default_popcorn_base_url,
**kwargs):
def __init__(
self,
download_dir=None,
torrent_ports=None,
imdb_key=None,
popcorn_base_url=default_popcorn_base_url,
**kwargs,
):
"""
:param download_dir: Directory where the videos/torrents will be downloaded (default: none)
:type download_dir: str
@ -66,7 +79,9 @@ class TorrentPlugin(Plugin):
self.imdb_key = imdb_key
self.imdb_urls = {}
self.torrent_ports = torrent_ports if torrent_ports else self.default_torrent_ports
self.torrent_ports = (
torrent_ports if torrent_ports else self.default_torrent_ports
)
self.download_dir = None
self._sessions = {}
self._lt_session = None
@ -109,11 +124,16 @@ class TorrentPlugin(Plugin):
# noinspection PyCallingNonCallable
def worker(cat):
if cat not in self.categories:
raise RuntimeError('Unsupported category {}. Supported category: {}'.
format(cat, self.categories.keys()))
raise RuntimeError(
'Unsupported category {}. Supported category: {}'.format(
cat, self.categories.keys()
)
)
self.logger.info('Searching {} torrents for "{}"'.format(cat, query))
results.extend(self.categories[cat](query, language=language, *args, **kwargs))
results.extend(
self.categories[cat](query, *args, language=language, **kwargs)
)
workers = [
threading.Thread(target=worker, kwargs={'cat': category})
@ -151,11 +171,14 @@ class TorrentPlugin(Plugin):
imdb_results = self._imdb_query(query, category)
result_queues = [queue.Queue()] * len(imdb_results)
workers = [
threading.Thread(target=self._torrent_search_worker, kwargs={
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))
]
@ -179,16 +202,20 @@ class TorrentPlugin(Plugin):
return results
@staticmethod
def _results_to_movies_response(results: List[dict], language: Optional[str] = None):
return sorted([
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': '{title} [movies][{language}][{quality}]'.format(
title=result.get('title'), language=lang, quality=quality),
'duration': int(result.get('runtime'), 0),
'year': int(result.get('year'), 0),
title=result.get('title'), language=lang, quality=quality
),
'duration': int(result.get('runtime') or 0),
'year': int(result.get('year') or 0),
'synopsis': result.get('synopsis'),
'trailer': result.get('trailer'),
'genres': result.get('genres', []),
@ -207,11 +234,15 @@ class TorrentPlugin(Plugin):
if not language or language == lang
for (quality, item) in items.items()
if quality != '0'
], key=lambda item: item.get('seeds', 0), reverse=True)
],
key=lambda item: item.get('seeds', 0),
reverse=True,
)
@staticmethod
def _results_to_tv_response(results: List[dict]):
return sorted([
return sorted(
[
{
'imdb_id': result.get('imdb_id'),
'tvdb_id': result.get('tvdb_id'),
@ -223,9 +254,10 @@ class TorrentPlugin(Plugin):
season=episode.get('season'),
episode=episode.get('episode'),
title=episode.get('title'),
quality=quality),
'duration': int(result.get('runtime'), 0),
'year': int(result.get('year'), 0),
quality=quality,
),
'duration': int(result.get('runtime') or 0),
'year': int(result.get('year') or 0),
'synopsis': result.get('synopsis'),
'overview': episode.get('overview'),
'season': episode.get('season'),
@ -247,17 +279,22 @@ class TorrentPlugin(Plugin):
for episode in result.get('episodes', [])
for quality, item in (episode.get('torrents', {}) or {}).items()
if quality != '0'
], key=lambda item: '{series}.{quality}.{season:02d}.{episode:02d}'.format(
series=item.get('series'), quality=item.get('quality'),
season=item.get('season'), episode=item.get('episode')))
],
key=lambda item: '{series}.{quality}.{season:02d}.{episode:02d}'.format(
series=item.get('series'),
quality=item.get('quality'),
season=item.get('season'),
episode=item.get('episode'),
),
)
def search_movies(self, query, language=None):
return self._results_to_movies_response(
self._search_torrents(query, 'movies'), language=language)
self._search_torrents(query, 'movies'), language=language
)
def search_tv(self, query, **_):
return self._results_to_tv_response(
self._search_torrents(query, 'tv'))
return self._results_to_tv_response(self._search_torrents(query, 'tv'))
def _get_torrent_info(self, torrent, download_dir):
import libtorrent as lt
@ -296,7 +333,9 @@ class TorrentPlugin(Plugin):
else:
torrent_file = os.path.abspath(os.path.expanduser(torrent))
if not os.path.isfile(torrent_file):
raise RuntimeError('{} is not a valid torrent file'.format(torrent_file))
raise RuntimeError(
'{} is not a valid torrent file'.format(torrent_file)
)
if torrent_file:
file_info = lt.torrent_info(torrent_file)
@ -330,7 +369,9 @@ class TorrentPlugin(Plugin):
while not transfer.is_finished():
if torrent not in self.transfers:
self.logger.info('Torrent {} has been stopped and removed'.format(torrent))
self.logger.info(
'Torrent {} has been stopped and removed'.format(torrent)
)
self._fire_event(TorrentDownloadStopEvent(url=torrent), event_hndl)
break
@ -339,14 +380,14 @@ class TorrentPlugin(Plugin):
if torrent_file:
self.torrent_state[torrent]['size'] = torrent_file.total_size()
files = [os.path.join(
download_dir,
torrent_file.files().file_path(i))
files = [
os.path.join(download_dir, torrent_file.files().file_path(i))
for i in range(0, torrent_file.files().num_files())
]
if is_media:
from platypush.plugins.media import MediaPlugin
# noinspection PyProtectedMember
files = [f for f in files if MediaPlugin.is_video_file(f)]
@ -354,7 +395,9 @@ class TorrentPlugin(Plugin):
self.torrent_state[torrent]['name'] = status.name
self.torrent_state[torrent]['num_peers'] = status.num_peers
self.torrent_state[torrent]['paused'] = status.paused
self.torrent_state[torrent]['progress'] = round(100 * status.progress, 2)
self.torrent_state[torrent]['progress'] = round(
100 * status.progress, 2
)
self.torrent_state[torrent]['state'] = status.state.name
self.torrent_state[torrent]['title'] = status.name
self.torrent_state[torrent]['torrent'] = torrent
@ -363,30 +406,51 @@ class TorrentPlugin(Plugin):
self.torrent_state[torrent]['files'] = files
if transfer.has_metadata() and not metadata_downloaded:
self._fire_event(TorrentDownloadedMetadataEvent(**self.torrent_state[torrent]), event_hndl)
self._fire_event(
TorrentDownloadedMetadataEvent(**self.torrent_state[torrent]),
event_hndl,
)
metadata_downloaded = True
if status.state == status.downloading and not download_started:
self._fire_event(TorrentDownloadStartEvent(**self.torrent_state[torrent]), event_hndl)
self._fire_event(
TorrentDownloadStartEvent(**self.torrent_state[torrent]),
event_hndl,
)
download_started = True
if last_status and status.progress != last_status.progress:
self._fire_event(TorrentDownloadProgressEvent(**self.torrent_state[torrent]), event_hndl)
self._fire_event(
TorrentDownloadProgressEvent(**self.torrent_state[torrent]),
event_hndl,
)
if not last_status or status.state != last_status.state:
self._fire_event(TorrentStateChangeEvent(**self.torrent_state[torrent]), event_hndl)
self._fire_event(
TorrentStateChangeEvent(**self.torrent_state[torrent]),
event_hndl,
)
if last_status and status.paused != last_status.paused:
if status.paused:
self._fire_event(TorrentPausedEvent(**self.torrent_state[torrent]), event_hndl)
self._fire_event(
TorrentPausedEvent(**self.torrent_state[torrent]),
event_hndl,
)
else:
self._fire_event(TorrentResumedEvent(**self.torrent_state[torrent]), event_hndl)
self._fire_event(
TorrentResumedEvent(**self.torrent_state[torrent]),
event_hndl,
)
last_status = status
time.sleep(self._MONITOR_CHECK_INTERVAL)
if transfer and transfer.is_finished():
self._fire_event(TorrentDownloadCompletedEvent(**self.torrent_state[torrent]), event_hndl)
self._fire_event(
TorrentDownloadCompletedEvent(**self.torrent_state[torrent]),
event_hndl,
)
self.remove(torrent)
return files
@ -398,12 +462,15 @@ class TorrentPlugin(Plugin):
return self._lt_session
import libtorrent as lt
# noinspection PyArgumentList
self._lt_session = lt.session()
return self._lt_session
@action
def download(self, torrent, download_dir=None, _async=False, event_hndl=None, is_media=False):
def download(
self, torrent, download_dir=None, _async=False, event_hndl=None, is_media=False
):
"""
Download a torrent.
@ -445,10 +512,14 @@ class TorrentPlugin(Plugin):
download_dir = os.path.abspath(os.path.expanduser(download_dir))
os.makedirs(download_dir, exist_ok=True)
info, file_info, torrent_file, magnet = self._get_torrent_info(torrent, download_dir)
info, file_info, torrent_file, magnet = self._get_torrent_info(
torrent, download_dir
)
if torrent in self._sessions:
self.logger.info('A torrent session is already running for {}'.format(torrent))
self.logger.info(
'A torrent session is already running for {}'.format(torrent)
)
return self.torrent_state.get(torrent, {})
session = self._get_session()
@ -472,12 +543,22 @@ class TorrentPlugin(Plugin):
'title': transfer.status().name,
'trackers': info['trackers'],
'save_path': download_dir,
'torrent_file': torrent_file,
}
self._fire_event(TorrentQueuedEvent(url=torrent), event_hndl)
self.logger.info('Downloading "{}" to "{}" from [{}]'.format(info['name'], download_dir, torrent))
monitor_thread = self._torrent_monitor(torrent=torrent, transfer=transfer, download_dir=download_dir,
event_hndl=event_hndl, is_media=is_media)
self.logger.info(
'Downloading "{}" to "{}" from [{}]'.format(
info['name'], download_dir, torrent
)
)
monitor_thread = self._torrent_monitor(
torrent=torrent,
transfer=transfer,
download_dir=download_dir,
event_hndl=event_hndl,
is_media=is_media,
)
if not _async:
return monitor_thread()
@ -565,7 +646,7 @@ class TorrentPlugin(Plugin):
@staticmethod
def _generate_rand_filename(length=16):
name = ''
for i in range(0, length):
for _ in range(0, length):
name += hex(random.randint(0, 15))[2:].upper()
return name + '.torrent'

View file

@ -2,6 +2,6 @@ manifest:
events: {}
install:
pip:
- python-libtorrent-bin
- python-libtorrent
package: platypush.plugins.torrent
type: plugin

View file

@ -7,7 +7,6 @@ bcrypt
croniter
flask
frozendict
gunicorn
marshmallow
marshmallow_dataclass
paho-mqtt
@ -18,10 +17,9 @@ pyyaml
redis
requests
rsa
simple_websocket
sqlalchemy
tornado
tz
uvicorn
websocket-client
wsproto
websockets
zeroconf>=0.27.0

View file

@ -65,7 +65,6 @@ setup(
'croniter',
'flask',
'frozendict',
'gunicorn',
'marshmallow',
'marshmallow_dataclass',
'python-dateutil',
@ -74,13 +73,12 @@ setup(
'redis',
'requests',
'rsa',
'simple_websocket',
'sqlalchemy',
'tornado',
'tz',
'uvicorn',
'websocket-client',
'websockets',
'wheel',
'wsproto',
'zeroconf>=0.27.0',
],
extras_require={
@ -109,6 +107,7 @@ setup(
'google-tts': [
'oauth2client',
'google-api-python-client',
'google-auth',
'google-cloud-texttospeech',
],
# Support for OMXPlayer plugin
@ -116,7 +115,7 @@ setup(
# Support for YouTube
'youtube': ['youtube-dl'],
# Support for torrents download
'torrent': ['python-libtorrent-bin'],
'torrent': ['python-libtorrent'],
# Generic support for cameras
'camera': ['numpy', 'Pillow'],
# Support for RaspberryPi camera
@ -124,10 +123,10 @@ setup(
# Support for inotify file monitors
'inotify': ['inotify'],
# Support for Google Assistant
'google-assistant-legacy': ['google-assistant-library'],
'google-assistant': ['google-assistant-sdk[samples]'],
'google-assistant-legacy': ['google-assistant-library', 'google-auth'],
'google-assistant': ['google-assistant-sdk[samples]', 'google-auth'],
# Support for the Google APIs
'google': ['oauth2client', 'google-api-python-client'],
'google': ['oauth2client', 'google-auth', 'google-api-python-client'],
# Support for Last.FM scrobbler plugin
'lastfm': ['pylast'],
# Support for custom hotword detection
@ -171,7 +170,7 @@ setup(
# Support for BME280 environment sensor
'bme280': ['pimoroni-bme280'],
# Support for LTR559 light/proximity sensor
'ltr559': ['ltr559'],
'ltr559': ['ltr559', 'smbus'],
# Support for VL53L1X laser ranger/distance sensor
'vl53l1x': ['smbus2', 'vl53l1x'],
# Support for Dropbox integration
@ -212,9 +211,9 @@ setup(
# Support for Trello integration
'trello': ['py-trello'],
# Support for Google Pub/Sub
'google-pubsub': ['google-cloud-pubsub'],
'google-pubsub': ['google-cloud-pubsub', 'google-auth'],
# Support for Google Translate
'google-translate': ['google-cloud-translate'],
'google-translate': ['google-cloud-translate', 'google-auth'],
# Support for keyboard/mouse plugin
'inputs': ['pyuserinput'],
# Support for Buienradar weather forecast