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', 'TheengsDecoder',
'simple_websocket', 'simple_websocket',
'uvicorn', 'uvicorn',
'websockets',
] ]
sys.path.insert(0, os.path.abspath('../..')) sys.path.insert(0, os.path.abspath('../..'))

View File

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

View File

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

View File

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

View File

@ -3,13 +3,17 @@ import pathlib
import secrets import secrets
import threading import threading
from multiprocessing import Process, cpu_count from multiprocessing import Process
from typing import Mapping, Optional 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 import Backend
from platypush.backend.http.app import application from platypush.backend.http.app import application
from platypush.backend.http.ws import events_redis_topic from platypush.backend.http.ws import WSEventProxy, events_redis_topic
from platypush.backend.http.wsgi import WSGIApplicationWrapper
from platypush.bus.redis import RedisBus from platypush.bus.redis import RedisBus
from platypush.config import Config from platypush.config import Config
from platypush.utils import get_redis from platypush.utils import get_redis
@ -177,6 +181,7 @@ class HttpBackend(Backend):
self.server_proc = None self.server_proc = None
self._service_registry_thread = None self._service_registry_thread = None
self.bind_address = bind_address self.bind_address = bind_address
self._io_loop: Optional[IOLoop] = None
if resource_dirs: if resource_dirs:
self.resource_dirs = { self.resource_dirs = {
@ -200,6 +205,10 @@ class HttpBackend(Backend):
super().on_stop() super().on_stop()
self.logger.info('Received STOP event on HttpBackend') self.logger.info('Received STOP event on HttpBackend')
if self._io_loop:
self._io_loop.stop()
self._io_loop.close()
if self.server_proc: if self.server_proc:
self.server_proc.terminate() self.server_proc.terminate()
self.server_proc.join(timeout=10) self.server_proc.join(timeout=10)
@ -216,6 +225,8 @@ class HttpBackend(Backend):
self._service_registry_thread.join(timeout=5) self._service_registry_thread.join(timeout=5)
self._service_registry_thread = None self._service_registry_thread = None
self._io_loop = None
def notify_web_clients(self, event): def notify_web_clients(self, event):
"""Notify all the connected web clients (over websocket) of a new event""" """Notify all the connected web clients (over websocket) of a new event"""
get_redis().publish(events_redis_topic, str(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.config['redis_queue'] = self.bus.redis_queue
application.secret_key = self._get_secret_key() 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 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 import json
from platypush.backend import Backend from platypush.backend import Backend
from platypush.message.event.nfc import NFCTagDetectedEvent, NFCTagRemovedEvent, NFCDeviceConnectedEvent, \ from platypush.message.event.nfc import (
NFCDeviceDisconnectedEvent NFCTagDetectedEvent,
NFCTagRemovedEvent,
NFCDeviceConnectedEvent,
NFCDeviceDisconnectedEvent,
)
class NfcBackend(Backend): class NfcBackend(Backend):
@ -20,7 +24,7 @@ class NfcBackend(Backend):
Requires: Requires:
* **nfcpy** >= 1.0 (``pip install 'nfcpy>=1.0'``) * **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:: 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 = nfc.ContactlessFrontend()
self._clf.open(self.device_id) self._clf.open(self.device_id)
self.bus.post(NFCDeviceConnectedEvent(reader=self._get_device_str())) 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 return self._clf
@ -107,51 +115,92 @@ class NfcBackend(Backend):
r = { r = {
**r, **r,
'type': 'smartposter', '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): elif isinstance(record, DeviceInformationRecord):
r = { r = {
**r, **r,
'type': 'device_info', '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): elif isinstance(record, WifiSimpleConfigRecord):
r = { r = {
**r, **r,
'type': 'wifi_simple_config', '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): elif isinstance(record, WifiPeerToPeerRecord):
r = { r = {
**r, **r,
'type': 'wifi_peer_to_peer', '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): elif isinstance(record, BluetoothEasyPairingRecord):
r = { r = {
**r, **r,
'type': 'bluetooth_easy_pairing', '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): elif isinstance(record, BluetoothLowEnergyRecord):
r = { r = {
**r, **r,
'type': 'bluetooth_low_energy', '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)
'secure_connections_confirmation_value', for attr in [
'secure_connections_random_value']}, 'device_address',
'device_name',
'role_capabilities',
'appearance',
'flags',
'security_manager_tk_value',
'secure_connections_confirmation_value',
'secure_connections_random_value',
]
},
} }
elif isinstance(record, SignatureRecord): elif isinstance(record, SignatureRecord):
r = { r = {
**r, **r,
'type': 'signature', 'type': 'signature',
**{attr: getattr(record, attr) for attr in ['version', 'signature_type', 'hash_type', 'signature', **{
'signature_uri', 'certificate_format', attr: getattr(record, attr)
'certificate_store', 'certificate_uri', for attr in [
'secure_connections_random_value']}, 'version',
'signature_type',
'hash_type',
'signature',
'signature_uri',
'certificate_format',
'certificate_store',
'certificate_uri',
'secure_connections_random_value',
]
},
} }
else: else:
r = { r = {
@ -175,7 +224,11 @@ class NfcBackend(Backend):
tag_id = self._parse_id(tag) tag_id = self._parse_id(tag)
records = self._parse_records(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 True
return callback return callback
@ -183,7 +236,9 @@ class NfcBackend(Backend):
def _on_release(self): def _on_release(self):
def callback(tag): def callback(tag):
tag_id = self._parse_id(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 return callback
@ -193,10 +248,12 @@ class NfcBackend(Backend):
while not self.should_stop(): while not self.should_stop():
try: try:
clf = self._get_clf() clf = self._get_clf()
clf.connect(rdwr={ clf.connect(
'on-connect': self._on_connect(), rdwr={
'on-release': self._on_release(), 'on-connect': self._on_connect(),
}) 'on-release': self._on_release(),
}
)
finally: finally:
self.close() self.close()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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