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',
|
'TheengsDecoder',
|
||||||
'simple_websocket',
|
'simple_websocket',
|
||||||
'uvicorn',
|
'uvicorn',
|
||||||
|
'websockets',
|
||||||
]
|
]
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath('../..'))
|
sys.path.insert(0, os.path.abspath('../..'))
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
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()
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -19,6 +19,7 @@ class SensorLtr559Plugin(SensorPlugin):
|
||||||
Requires:
|
Requires:
|
||||||
|
|
||||||
* ``ltr559`` (``pip install ltr559``)
|
* ``ltr559`` (``pip install ltr559``)
|
||||||
|
* ``smbus`` (``pip install smbus``)
|
||||||
|
|
||||||
Triggers:
|
Triggers:
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
21
setup.py
21
setup.py
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue