forked from platypush/platypush
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:
parent
f81e9061a3
commit
56dc8d0972
33 changed files with 837 additions and 438 deletions
|
@ -304,6 +304,7 @@ autodoc_mock_imports = [
|
|||
'TheengsDecoder',
|
||||
'simple_websocket',
|
||||
'uvicorn',
|
||||
'websockets',
|
||||
]
|
||||
|
||||
sys.path.insert(0, os.path.abspath('../..'))
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -22,5 +22,6 @@ manifest:
|
|||
pip:
|
||||
- google-assistant-library
|
||||
- google-assistant-sdk[samples]
|
||||
- google-auth
|
||||
package: platypush.backend.assistant.google
|
||||
type: backend
|
||||
|
|
|
@ -3,5 +3,7 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- picamera
|
||||
- numpy
|
||||
- Pillow
|
||||
package: platypush.backend.camera.pi
|
||||
type: backend
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
|
@ -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',
|
||||
'secure_connections_confirmation_value',
|
||||
'secure_connections_random_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',
|
||||
]
|
||||
},
|
||||
}
|
||||
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={
|
||||
'on-connect': self._on_connect(),
|
||||
'on-release': self._on_release(),
|
||||
})
|
||||
clf.connect(
|
||||
rdwr={
|
||||
'on-connect': self._on_connect(),
|
||||
'on-release': self._on_release(),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
language='en-US',
|
||||
play_response=True,
|
||||
tts_plugin=None,
|
||||
tts_args=None,
|
||||
**kwargs):
|
||||
_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
|
||||
):
|
||||
"""
|
||||
: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(
|
||||
sample_rate=self.audio_sample_rate,
|
||||
sample_width=self.audio_sample_width,
|
||||
block_size=self.audio_block_size,
|
||||
flush_size=self.audio_flush_size
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
# 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,27 +274,31 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin):
|
|||
self._init_assistant()
|
||||
self.on_conversation_start()
|
||||
|
||||
with SampleAssistant(language_code=language,
|
||||
device_model_id=self.device_model_id,
|
||||
device_id=self.device_id,
|
||||
conversation_stream=self.conversation_stream,
|
||||
display=None,
|
||||
channel=self.grpc_channel,
|
||||
deadline_sec=self.grpc_deadline,
|
||||
play_response=play_response,
|
||||
device_handler=self.device_handler,
|
||||
on_conversation_start=self.on_conversation_start(),
|
||||
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:
|
||||
with SampleAssistant(
|
||||
language_code=language,
|
||||
device_model_id=self.device_model_id,
|
||||
device_id=self.device_id,
|
||||
conversation_stream=self.conversation_stream,
|
||||
display=None,
|
||||
channel=self.grpc_channel,
|
||||
deadline_sec=self.grpc_deadline,
|
||||
play_response=play_response,
|
||||
device_handler=self.device_handler,
|
||||
on_conversation_start=self.on_conversation_start(),
|
||||
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:
|
||||
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(
|
||||
device_id=self.device_id,
|
||||
device_model_id=self.device_model_id,
|
||||
on=on))
|
||||
def handler(on): # type: ignore
|
||||
get_bus().post(
|
||||
GoogleDeviceOnOffEvent(
|
||||
device_id=self.device_id,
|
||||
device_model_id=self.device_model_id,
|
||||
on=on,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -10,5 +10,6 @@ manifest:
|
|||
pip:
|
||||
- tenacity
|
||||
- google-assistant-sdk
|
||||
- google-auth
|
||||
package: platypush.plugins.assistant.google.pushtotalk
|
||||
type: plugin
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ manifest:
|
|||
pip:
|
||||
- numpy
|
||||
- Pillow
|
||||
- pygobject
|
||||
apt:
|
||||
- python3-gi
|
||||
- python3-gst-1.0
|
||||
|
|
|
@ -3,6 +3,7 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- google-api-python-client
|
||||
- google-auth
|
||||
- oauth2client
|
||||
package: platypush.plugins.google.calendar
|
||||
type: plugin
|
||||
|
|
|
@ -3,6 +3,7 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- google-api-python-client
|
||||
- google-auth
|
||||
- oauth2client
|
||||
package: platypush.plugins.google.drive
|
||||
type: plugin
|
||||
|
|
|
@ -3,6 +3,7 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- google-api-python-client
|
||||
- google-auth
|
||||
- oauth2client
|
||||
package: platypush.plugins.google.fit
|
||||
type: plugin
|
||||
|
|
|
@ -3,6 +3,7 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- google-api-python-client
|
||||
- google-auth
|
||||
- oauth2client
|
||||
package: platypush.plugins.google.mail
|
||||
type: plugin
|
||||
|
|
|
@ -3,6 +3,7 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- google-api-python-client
|
||||
- google-auth
|
||||
- oauth2client
|
||||
package: platypush.plugins.google.maps
|
||||
type: plugin
|
||||
|
|
|
@ -3,6 +3,7 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- google-api-python-client
|
||||
- google-auth
|
||||
- oauth2client
|
||||
- google-cloud-pubsub
|
||||
package: platypush.plugins.google.pubsub
|
||||
|
|
|
@ -3,6 +3,7 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- google-api-python-client
|
||||
- google-auth
|
||||
- oauth2client
|
||||
- google-cloud-translate
|
||||
package: platypush.plugins.google.translate
|
||||
|
|
|
@ -3,6 +3,7 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- google-api-python-client
|
||||
- google-auth
|
||||
- oauth2client
|
||||
package: platypush.plugins.google.youtube
|
||||
type: plugin
|
||||
|
|
|
@ -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,
|
||||
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):
|
||||
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',
|
||||
**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=') \
|
||||
or resource.startswith('https://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()
|
||||
for x in result.stdout.readlines()
|
||||
if "Duration" in x.decode()].pop().strip()
|
||||
).group(1).split(':')[::-1])]
|
||||
[
|
||||
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]
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@ manifest:
|
|||
apt:
|
||||
- python3-gi
|
||||
- python3-gst-1.0
|
||||
pip:
|
||||
- pygobject
|
||||
pacman:
|
||||
- gst-python
|
||||
- python-gobject
|
||||
|
|
|
@ -3,5 +3,7 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- pycups
|
||||
apt:
|
||||
- libcups2-dev
|
||||
package: platypush.plugins.printer.cups
|
||||
type: plugin
|
||||
|
|
|
@ -19,6 +19,7 @@ class SensorLtr559Plugin(SensorPlugin):
|
|||
Requires:
|
||||
|
||||
* ``ltr559`` (``pip install ltr559``)
|
||||
* ``smbus`` (``pip install smbus``)
|
||||
|
||||
Triggers:
|
||||
|
||||
|
|
|
@ -6,5 +6,6 @@ manifest:
|
|||
install:
|
||||
pip:
|
||||
- ltr559
|
||||
- smbus
|
||||
package: platypush.plugins.sensor.ltr559
|
||||
type: plugin
|
||||
|
|
|
@ -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={
|
||||
'imdb_id': imdb_results[i]['id'],
|
||||
'category': category,
|
||||
'q': result_queues[i],
|
||||
})
|
||||
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,85 +202,99 @@ class TorrentPlugin(Plugin):
|
|||
return results
|
||||
|
||||
@staticmethod
|
||||
def _results_to_movies_response(results: List[dict], language: Optional[str] = None):
|
||||
return sorted([
|
||||
{
|
||||
'imdb_id': result.get('imdb_id'),
|
||||
'type': 'movies',
|
||||
'file': item.get('file'),
|
||||
'title': '{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),
|
||||
'synopsis': result.get('synopsis'),
|
||||
'trailer': result.get('trailer'),
|
||||
'genres': result.get('genres', []),
|
||||
'images': result.get('images', []),
|
||||
'rating': result.get('rating', {}),
|
||||
'language': lang,
|
||||
'quality': quality,
|
||||
'size': item.get('size'),
|
||||
'provider': item.get('provider'),
|
||||
'seeds': item.get('seed'),
|
||||
'peers': item.get('peer'),
|
||||
'url': item.get('url'),
|
||||
}
|
||||
for result in results
|
||||
for (lang, items) in (result.get('torrents', {}) or {}).items()
|
||||
if not language or language == lang
|
||||
for (quality, item) in items.items()
|
||||
if quality != '0'
|
||||
], key=lambda item: item.get('seeds', 0), reverse=True)
|
||||
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') or 0),
|
||||
'year': int(result.get('year') or 0),
|
||||
'synopsis': result.get('synopsis'),
|
||||
'trailer': result.get('trailer'),
|
||||
'genres': result.get('genres', []),
|
||||
'images': result.get('images', []),
|
||||
'rating': result.get('rating', {}),
|
||||
'language': lang,
|
||||
'quality': quality,
|
||||
'size': item.get('size'),
|
||||
'provider': item.get('provider'),
|
||||
'seeds': item.get('seed'),
|
||||
'peers': item.get('peer'),
|
||||
'url': item.get('url'),
|
||||
}
|
||||
for result in results
|
||||
for (lang, items) in (result.get('torrents', {}) or {}).items()
|
||||
if not language or language == lang
|
||||
for (quality, item) in items.items()
|
||||
if quality != '0'
|
||||
],
|
||||
key=lambda item: item.get('seeds', 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _results_to_tv_response(results: List[dict]):
|
||||
return sorted([
|
||||
{
|
||||
'imdb_id': result.get('imdb_id'),
|
||||
'tvdb_id': result.get('tvdb_id'),
|
||||
'type': 'tv',
|
||||
'file': item.get('file'),
|
||||
'series': result.get('title'),
|
||||
'title': '{series} [S{season:02d}E{episode:02d}] {title} [tv][{quality}]'.format(
|
||||
series=result.get('title'),
|
||||
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),
|
||||
'synopsis': result.get('synopsis'),
|
||||
'overview': episode.get('overview'),
|
||||
'season': episode.get('season'),
|
||||
'episode': episode.get('episode'),
|
||||
'num_seasons': result.get('num_seasons'),
|
||||
'country': result.get('country'),
|
||||
'network': result.get('network'),
|
||||
'status': result.get('status'),
|
||||
'genres': result.get('genres', []),
|
||||
'images': result.get('images', []),
|
||||
'rating': result.get('rating', {}),
|
||||
'quality': quality,
|
||||
'provider': item.get('provider'),
|
||||
'seeds': item.get('seeds'),
|
||||
'peers': item.get('peers'),
|
||||
'url': item.get('url'),
|
||||
}
|
||||
for result in results
|
||||
for episode in result.get('episodes', [])
|
||||
for quality, item in (episode.get('torrents', {}) or {}).items()
|
||||
if quality != '0'
|
||||
], key=lambda item: '{series}.{quality}.{season:02d}.{episode:02d}'.format(
|
||||
series=item.get('series'), quality=item.get('quality'),
|
||||
season=item.get('season'), episode=item.get('episode')))
|
||||
return sorted(
|
||||
[
|
||||
{
|
||||
'imdb_id': result.get('imdb_id'),
|
||||
'tvdb_id': result.get('tvdb_id'),
|
||||
'type': 'tv',
|
||||
'file': item.get('file'),
|
||||
'series': result.get('title'),
|
||||
'title': '{series} [S{season:02d}E{episode:02d}] {title} [tv][{quality}]'.format(
|
||||
series=result.get('title'),
|
||||
season=episode.get('season'),
|
||||
episode=episode.get('episode'),
|
||||
title=episode.get('title'),
|
||||
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'),
|
||||
'episode': episode.get('episode'),
|
||||
'num_seasons': result.get('num_seasons'),
|
||||
'country': result.get('country'),
|
||||
'network': result.get('network'),
|
||||
'status': result.get('status'),
|
||||
'genres': result.get('genres', []),
|
||||
'images': result.get('images', []),
|
||||
'rating': result.get('rating', {}),
|
||||
'quality': quality,
|
||||
'provider': item.get('provider'),
|
||||
'seeds': item.get('seeds'),
|
||||
'peers': item.get('peers'),
|
||||
'url': item.get('url'),
|
||||
}
|
||||
for result in results
|
||||
for episode in result.get('episodes', [])
|
||||
for quality, item in (episode.get('torrents', {}) or {}).items()
|
||||
if quality != '0'
|
||||
],
|
||||
key=lambda item: '{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'
|
||||
|
||||
|
|
|
@ -2,6 +2,6 @@ manifest:
|
|||
events: {}
|
||||
install:
|
||||
pip:
|
||||
- python-libtorrent-bin
|
||||
- python-libtorrent
|
||||
package: platypush.plugins.torrent
|
||||
type: plugin
|
||||
|
|
|
@ -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
|
||||
|
|
21
setup.py
21
setup.py
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue