Support for media streaming over internal HTTP server
Dropped the additional node.js dependency of localstream and relied instead on a built-in solution to stream files
This commit is contained in:
parent
077bb0686b
commit
9ec3365413
9 changed files with 350 additions and 178 deletions
|
@ -1,13 +1,14 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from threading import Thread, get_ident
|
|
||||||
from multiprocessing import Process
|
from multiprocessing import Process
|
||||||
from flask import Flask, Response, abort, jsonify, request as http_request, \
|
from flask import Flask, Response, abort, jsonify, request as http_request, \
|
||||||
render_template, send_from_directory
|
render_template, send_from_directory
|
||||||
|
@ -20,9 +21,11 @@ from platypush.message import Message
|
||||||
from platypush.message.event import Event, StopEvent
|
from platypush.message.event import Event, StopEvent
|
||||||
from platypush.message.event.web.widget import WidgetUpdateEvent
|
from platypush.message.event.web.widget import WidgetUpdateEvent
|
||||||
from platypush.message.request import Request
|
from platypush.message.request import Request
|
||||||
from platypush.utils import get_ssl_server_context, set_thread_name
|
from platypush.utils import get_ssl_server_context, set_thread_name, \
|
||||||
|
get_ip_or_hostname
|
||||||
|
|
||||||
from .. import Backend
|
from .. import Backend
|
||||||
|
from .media.handlers import MediaHandler
|
||||||
|
|
||||||
|
|
||||||
class HttpBackend(Backend):
|
class HttpBackend(Backend):
|
||||||
|
@ -55,15 +58,25 @@ class HttpBackend(Backend):
|
||||||
* **redis** (``pip install redis``)
|
* **redis** (``pip install redis``)
|
||||||
* **websockets** (``pip install websockets``)
|
* **websockets** (``pip install websockets``)
|
||||||
* **python-dateutil** (``pip install python-dateutil``)
|
* **python-dateutil** (``pip install python-dateutil``)
|
||||||
|
* **magic** (``pip install python-magic``), optional, for MIME type
|
||||||
|
support if you want to enable media streaming
|
||||||
"""
|
"""
|
||||||
|
|
||||||
hidden_plugins = {
|
hidden_plugins = {
|
||||||
'assistant.google'
|
'assistant.google'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Default size for the bytes chunk sent over the media streaming infra
|
||||||
|
_DEFAULT_STREAMING_CHUNK_SIZE = 4096
|
||||||
|
|
||||||
|
# Maximum range size to be sent through the media streamer if Range header
|
||||||
|
# is not set
|
||||||
|
_DEFAULT_STREAMING_BLOCK_SIZE = 3145728
|
||||||
|
|
||||||
def __init__(self, port=8008, websocket_port=8009, disable_websocket=False,
|
def __init__(self, port=8008, websocket_port=8009, disable_websocket=False,
|
||||||
redis_queue='platypush/http', dashboard={}, resource_dirs={},
|
redis_queue='platypush/http', dashboard={}, resource_dirs={},
|
||||||
ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None,
|
ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None,
|
||||||
|
streaming_chunk_size=_DEFAULT_STREAMING_CHUNK_SIZE,
|
||||||
maps={}, **kwargs):
|
maps={}, **kwargs):
|
||||||
"""
|
"""
|
||||||
:param port: Listen port for the web server (default: 8008)
|
:param port: Listen port for the web server (default: 8008)
|
||||||
|
@ -125,12 +138,17 @@ class HttpBackend(Backend):
|
||||||
db: "sqlite:////home/blacklight/.local/share/platypush/feeds/rss.db"
|
db: "sqlite:////home/blacklight/.local/share/platypush/feeds/rss.db"
|
||||||
|
|
||||||
:type dashboard: dict
|
:type dashboard: dict
|
||||||
|
|
||||||
|
:param streaming_chunk_size: Size for the chunks of bytes sent over the
|
||||||
|
media streaming infrastructure (default: 4096 bytes)
|
||||||
|
:type streaming_chunk_size: int
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
self.port = port
|
self.port = port
|
||||||
self.websocket_port = websocket_port
|
self.websocket_port = websocket_port
|
||||||
|
self.app = None
|
||||||
self.redis_queue = redis_queue
|
self.redis_queue = redis_queue
|
||||||
self.dashboard = dashboard
|
self.dashboard = dashboard
|
||||||
self.maps = maps
|
self.maps = maps
|
||||||
|
@ -148,6 +166,15 @@ class HttpBackend(Backend):
|
||||||
ssl_capath=ssl_capath) \
|
ssl_capath=ssl_capath) \
|
||||||
if ssl_cert else None
|
if ssl_cert else None
|
||||||
|
|
||||||
|
self.remote_base_url = '{proto}://{host}:{port}'.format(
|
||||||
|
proto=('https' if self.ssl_context else 'http'),
|
||||||
|
host=get_ip_or_hostname(), port=self.port)
|
||||||
|
|
||||||
|
self.local_base_url = '{proto}://localhost:{port}'.format(
|
||||||
|
proto=('https' if self.ssl_context else 'http'), port=self.port)
|
||||||
|
|
||||||
|
self._media_map_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
def send_message(self, msg):
|
def send_message(self, msg):
|
||||||
self.logger.warning('Use cURL or any HTTP client to query the HTTP backend')
|
self.logger.warning('Use cURL or any HTTP client to query the HTTP backend')
|
||||||
|
@ -241,8 +268,10 @@ class HttpBackend(Backend):
|
||||||
|
|
||||||
basedir = os.path.dirname(inspect.getfile(self.__class__))
|
basedir = os.path.dirname(inspect.getfile(self.__class__))
|
||||||
template_dir = os.path.join(basedir, 'templates')
|
template_dir = os.path.join(basedir, 'templates')
|
||||||
|
media_map = {}
|
||||||
app = Flask(__name__, template_folder=template_dir)
|
app = Flask(__name__, template_folder=template_dir)
|
||||||
self.redis_thread = Thread(target=self.redis_poll)
|
|
||||||
|
self.redis_thread = threading.Thread(target=self.redis_poll)
|
||||||
self.redis_thread.start()
|
self.redis_thread.start()
|
||||||
|
|
||||||
@app.route('/execute', methods=['POST'])
|
@app.route('/execute', methods=['POST'])
|
||||||
|
@ -335,6 +364,159 @@ class HttpBackend(Backend):
|
||||||
|
|
||||||
return send_from_directory(real_path, file_path)
|
return send_from_directory(real_path, file_path)
|
||||||
|
|
||||||
|
def get_media_url(media_id):
|
||||||
|
return '{url}/media/{media_id}'.format(
|
||||||
|
url=self.remote_base_url, media_id=media_id)
|
||||||
|
|
||||||
|
def get_media_id(source):
|
||||||
|
return hashlib.sha1(source.encode()).hexdigest()
|
||||||
|
|
||||||
|
def register_media(source):
|
||||||
|
media_id = get_media_id(source)
|
||||||
|
media_url = get_media_url(media_id)
|
||||||
|
|
||||||
|
with self._media_map_lock:
|
||||||
|
if media_id in media_map:
|
||||||
|
raise FileExistsError('"{}" is already registered on {}'.
|
||||||
|
format(source, media_map[media_id].url))
|
||||||
|
|
||||||
|
media_hndl = MediaHandler.build(source, url=media_url)
|
||||||
|
media_map[media_id] = media_hndl
|
||||||
|
|
||||||
|
self.logger.info('Streaming "{}" on {}'.format(source, media_url))
|
||||||
|
return media_hndl
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_media(source):
|
||||||
|
if source is None:
|
||||||
|
raise KeyError('No media_id specified')
|
||||||
|
|
||||||
|
media_id = get_media_id(source)
|
||||||
|
media_info = {}
|
||||||
|
|
||||||
|
with self._media_map_lock:
|
||||||
|
if media_id not in media_map:
|
||||||
|
raise FileNotFoundError('{} is not a registered media_id'.
|
||||||
|
format(source))
|
||||||
|
media_info = media_map.pop(media_id)
|
||||||
|
|
||||||
|
self.logger.info('Unregistered {} from {}'.format(
|
||||||
|
source, media_info.get('url')))
|
||||||
|
|
||||||
|
return media_info
|
||||||
|
|
||||||
|
|
||||||
|
def stream_media(media_id, request):
|
||||||
|
media_hndl = media_map.get(media_id)
|
||||||
|
if not media_hndl:
|
||||||
|
raise FileNotFoundError('{} is not a registered media_id'.
|
||||||
|
format(media_id))
|
||||||
|
|
||||||
|
from_bytes = None
|
||||||
|
to_bytes = None
|
||||||
|
range_hdr = request.headers.get('range')
|
||||||
|
content_length = media_hndl.content_length
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Type': media_hndl.mime_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
if 'download' in request.args:
|
||||||
|
headers['Content-Disposition'] = 'attachment' + \
|
||||||
|
('; filename="{}"'.format(media_hndl.filename) if
|
||||||
|
media_hndl.filename else '')
|
||||||
|
|
||||||
|
if range_hdr:
|
||||||
|
headers['Accept-Ranges'] = 'bytes'
|
||||||
|
from_bytes, to_bytes = range_hdr.replace('bytes=', '').split('-')
|
||||||
|
from_bytes = int(from_bytes)
|
||||||
|
|
||||||
|
if not to_bytes:
|
||||||
|
to_bytes = content_length-1
|
||||||
|
# to_bytes = from_bytes + self._DEFAULT_STREAMING_BLOCK_SIZE
|
||||||
|
content_length -= from_bytes
|
||||||
|
else:
|
||||||
|
to_bytes = int(to_bytes)
|
||||||
|
content_length = to_bytes - from_bytes
|
||||||
|
|
||||||
|
status_code = 206
|
||||||
|
headers['Content-Range'] = 'bytes {start}-{end}/{size}'.format(
|
||||||
|
start=from_bytes, end=to_bytes,
|
||||||
|
size=media_hndl.content_length)
|
||||||
|
else:
|
||||||
|
from_bytes = 0
|
||||||
|
to_bytes = self._DEFAULT_STREAMING_BLOCK_SIZE
|
||||||
|
|
||||||
|
headers['Content-Length'] = content_length
|
||||||
|
|
||||||
|
return Response(media_hndl.get_data(
|
||||||
|
from_bytes=from_bytes, to_bytes=to_bytes,
|
||||||
|
chunk_size=self._DEFAULT_STREAMING_CHUNK_SIZE),
|
||||||
|
status_code, headers=headers, mimetype=headers['Content-Type'],
|
||||||
|
direct_passthrough=True)
|
||||||
|
|
||||||
|
@app.route('/media', methods=['GET', 'PUT'])
|
||||||
|
def add_or_get_media():
|
||||||
|
"""
|
||||||
|
This route can be used by the `media` plugin to add streaming
|
||||||
|
content over HTTP or to get the list of registered streams
|
||||||
|
"""
|
||||||
|
|
||||||
|
if http_request.method == 'GET':
|
||||||
|
return jsonify([dict(media) for media in media_map.values()])
|
||||||
|
|
||||||
|
args = {}
|
||||||
|
try:
|
||||||
|
args = json.loads(http_request.data.decode('utf-8'))
|
||||||
|
except:
|
||||||
|
abort(400, 'Invalid JSON request')
|
||||||
|
|
||||||
|
source = args.get('source')
|
||||||
|
if not source:
|
||||||
|
abort(400, 'The request does not contain any source')
|
||||||
|
|
||||||
|
try:
|
||||||
|
media_hndl = register_media(source)
|
||||||
|
return jsonify(dict(media_hndl))
|
||||||
|
except FileExistsError as e:
|
||||||
|
abort(409, str(e))
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
abort(404, str(e))
|
||||||
|
except AttributeError as e:
|
||||||
|
abort(400, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.exception(e)
|
||||||
|
abort(500, str(e))
|
||||||
|
|
||||||
|
@app.route('/media/<media_id>', methods=['GET', 'DELETE'])
|
||||||
|
def stream_or_delete_media(media_id):
|
||||||
|
"""
|
||||||
|
This route can be used to stream active media points or unregister
|
||||||
|
a mounted media stream
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Remove the extension
|
||||||
|
media_id = '.'.join(media_id.split('.')[:-1])
|
||||||
|
|
||||||
|
try:
|
||||||
|
if http_request.method == 'GET':
|
||||||
|
if media_id is None:
|
||||||
|
return jsonify(media_map)
|
||||||
|
else:
|
||||||
|
return stream_media(media_id, http_request)
|
||||||
|
else:
|
||||||
|
media_info = unregister_media(media_id)
|
||||||
|
return jsonify(media_info)
|
||||||
|
except (AttributeError, FileNotFoundError) as e:
|
||||||
|
abort(404, str(e))
|
||||||
|
except KeyError as e:
|
||||||
|
abort(400, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.exception(e)
|
||||||
|
abort(500, str(e))
|
||||||
|
|
||||||
@app.route('/dashboard', methods=['GET'])
|
@app.route('/dashboard', methods=['GET'])
|
||||||
def dashboard():
|
def dashboard():
|
||||||
""" Route for the fullscreen dashboard """
|
""" Route for the fullscreen dashboard """
|
||||||
|
@ -448,7 +630,6 @@ class HttpBackend(Backend):
|
||||||
**websocket_args))
|
**websocket_args))
|
||||||
loop.run_forever()
|
loop.run_forever()
|
||||||
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
super().run()
|
super().run()
|
||||||
os.putenv('FLASK_APP', 'platypush')
|
os.putenv('FLASK_APP', 'platypush')
|
||||||
|
@ -462,14 +643,14 @@ class HttpBackend(Backend):
|
||||||
|
|
||||||
self.logger.info('Initialized HTTP backend on port {}'.format(self.port))
|
self.logger.info('Initialized HTTP backend on port {}'.format(self.port))
|
||||||
|
|
||||||
webserver = self.webserver()
|
self.app = self.webserver()
|
||||||
self.server_proc = Process(target=webserver.run,
|
self.server_proc = Process(target=self.app.run,
|
||||||
name='WebServer',
|
name='WebServer',
|
||||||
kwargs=kwargs)
|
kwargs=kwargs)
|
||||||
self.server_proc.start()
|
self.server_proc.start()
|
||||||
|
|
||||||
if not self.disable_websocket:
|
if not self.disable_websocket:
|
||||||
self.websocket_thread = Thread(target=self.websocket)
|
self.websocket_thread = threading.Thread(target=self.websocket)
|
||||||
self.websocket_thread.start()
|
self.websocket_thread.start()
|
||||||
|
|
||||||
self.server_proc.join()
|
self.server_proc.join()
|
||||||
|
|
0
platypush/backend/http/media/__init__.py
Normal file
0
platypush/backend/http/media/__init__.py
Normal file
61
platypush/backend/http/media/handlers/__init__.py
Normal file
61
platypush/backend/http/media/handlers/__init__.py
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
class MediaHandler:
|
||||||
|
"""
|
||||||
|
Abstract class to manage media handlers that can be streamed over the HTTP
|
||||||
|
server through the `/media` endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
prefix_handlers = []
|
||||||
|
|
||||||
|
def __init__(self, source, filename=None,
|
||||||
|
mime_type='application/octet-stream', name=None, url=None):
|
||||||
|
matched_handlers = [hndl for hndl in self.prefix_handlers
|
||||||
|
if source.startswith(hndl)]
|
||||||
|
|
||||||
|
if not matched_handlers:
|
||||||
|
raise AttributeError(('No matched handlers found for source "{}" ' +
|
||||||
|
'through {}. Supported handlers: {}').format(
|
||||||
|
source, self.__class__.__name__,
|
||||||
|
self.prefix_handlers))
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.filename = name
|
||||||
|
self.source = source
|
||||||
|
self.url = url
|
||||||
|
self.mime_type = mime_type
|
||||||
|
self.content_length = 0
|
||||||
|
self._matched_handler = matched_handlers[0]
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(cls, source, *args, **kwargs):
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
for hndl_class in supported_handlers:
|
||||||
|
try:
|
||||||
|
return hndl_class(source, *args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
errors[hndl_class.__name__] = str(e)
|
||||||
|
|
||||||
|
raise AttributeError(('The source {} has no handlers associated. ' +
|
||||||
|
'Errors: {}').format(source, errors))
|
||||||
|
|
||||||
|
def get_data(self, from_bytes=None, to_bytes=None, chunk_size=None):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
for attr in ['name', 'source', 'mime_type', 'url', 'prefix_handlers']:
|
||||||
|
yield (attr, getattr(self, attr))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from .file import FileHandler
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['MediaHandler', 'FileHandler']
|
||||||
|
|
||||||
|
|
||||||
|
supported_handlers = [eval(hndl) for hndl in __all__
|
||||||
|
if hndl != MediaHandler.__name__]
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
49
platypush/backend/http/media/handlers/file.py
Normal file
49
platypush/backend/http/media/handlers/file.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import functools
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
|
||||||
|
from platypush.utils import get_mime_type
|
||||||
|
|
||||||
|
from . import MediaHandler
|
||||||
|
|
||||||
|
|
||||||
|
class FileHandler(MediaHandler):
|
||||||
|
prefix_handlers = ['file://']
|
||||||
|
|
||||||
|
def __init__(self, source, *args, **kwargs):
|
||||||
|
super().__init__(source, *args, **kwargs)
|
||||||
|
|
||||||
|
self.path = os.path.abspath(os.path.expanduser(
|
||||||
|
self.source[len(self._matched_handler):]))
|
||||||
|
self.filename = self.path.split('/')[-1]
|
||||||
|
|
||||||
|
if not os.path.isfile(self.path):
|
||||||
|
raise FileNotFoundError('{} is not a valid file'.
|
||||||
|
format(self.path))
|
||||||
|
|
||||||
|
self.mime_type = get_mime_type(source)
|
||||||
|
if self.mime_type[:5] not in ['video', 'audio', 'image']:
|
||||||
|
raise AttributeError('{} is not a valid media file'.format(source))
|
||||||
|
|
||||||
|
self.extension = mimetypes.guess_extension(self.mime_type)
|
||||||
|
if self.url:
|
||||||
|
self.url += self.extension
|
||||||
|
self.content_length = os.path.getsize(self.path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_data(self, from_bytes=None, to_bytes=None, chunk_size=None):
|
||||||
|
if from_bytes is None:
|
||||||
|
from_bytes = 0
|
||||||
|
if to_bytes is None:
|
||||||
|
to_bytes = os.path.getsize(self.path)
|
||||||
|
if chunk_size is None:
|
||||||
|
chunk_size = os.path.getsize(self.path) - from_bytes
|
||||||
|
|
||||||
|
with open(self.path, 'rb') as f:
|
||||||
|
f.seek(from_bytes)
|
||||||
|
for chunk in iter(functools.partial(
|
||||||
|
f.read, min(to_bytes-from_bytes, chunk_size)), b''):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
|
@ -8,9 +8,9 @@ import urllib.request
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from platypush.config import Config
|
from platypush.config import Config
|
||||||
from platypush.context import get_plugin
|
from platypush.context import get_plugin, get_backend
|
||||||
from platypush.plugins import Plugin, action
|
from platypush.plugins import Plugin, action
|
||||||
from platypush.utils import get_ip_or_hostname, is_process_alive
|
|
||||||
|
|
||||||
class PlayerState(enum.Enum):
|
class PlayerState(enum.Enum):
|
||||||
STOP = 'stop'
|
STOP = 'stop'
|
||||||
|
@ -25,26 +25,19 @@ class MediaPlugin(Plugin):
|
||||||
Requires:
|
Requires:
|
||||||
|
|
||||||
* A media player installed (supported so far: mplayer, omxplayer, chromecast)
|
* A media player installed (supported so far: mplayer, omxplayer, chromecast)
|
||||||
* **python-libtorrent** (``pip install python-libtorrent``), optional for Torrent support
|
* The :class:`platypush.plugins.media.webtorrent` plugin for optional torrent support through webtorrent (recommented)
|
||||||
|
* **python-libtorrent** (``pip install python-libtorrent``), optional, for torrent support through the native Python plugin
|
||||||
* **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
|
||||||
|
* **requests** (``pip install requests``), optional, for local files over HTTP streaming supporting
|
||||||
|
|
||||||
To start the local media stream service over HTTP:
|
To start the local media stream service over HTTP you will also need the
|
||||||
|
:class:`platypush.backend.http.HttpBackend` backend enabled.
|
||||||
* **nodejs** installed on your system
|
|
||||||
* **express** module (``npm install express``)
|
|
||||||
* **mime-types** module (``npm install mime-types``)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
# Default port for the local resources HTTP streaming service
|
|
||||||
_default_streaming_port = 8989
|
|
||||||
|
|
||||||
# setup.py install will place localstream in PATH
|
|
||||||
_local_stream_bin = 'localstream'
|
|
||||||
|
|
||||||
_NOT_IMPLEMENTED_ERR = NotImplementedError(
|
_NOT_IMPLEMENTED_ERR = NotImplementedError(
|
||||||
'This method must be implemented in a derived class')
|
'This method must be implemented in a derived class')
|
||||||
|
|
||||||
|
@ -71,7 +64,7 @@ class MediaPlugin(Plugin):
|
||||||
'media.chromecast'}
|
'media.chromecast'}
|
||||||
|
|
||||||
def __init__(self, media_dirs=[], download_dir=None, env=None,
|
def __init__(self, media_dirs=[], download_dir=None, env=None,
|
||||||
streaming_port=_default_streaming_port, *args, **kwargs):
|
*args, **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)
|
||||||
|
@ -84,10 +77,6 @@ class MediaPlugin(Plugin):
|
||||||
:param env: Environment variables key-values to pass to the
|
:param env: Environment variables key-values to pass to the
|
||||||
player executable (e.g. DISPLAY, XDG_VTNR, PULSE_SINK etc.)
|
player executable (e.g. DISPLAY, XDG_VTNR, PULSE_SINK etc.)
|
||||||
:type env: dict
|
:type env: dict
|
||||||
|
|
||||||
:param streaming_port: Port to be used for streaming local resources
|
|
||||||
over HTTP (default: 8989)
|
|
||||||
:type streaming_port: int
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -139,10 +128,6 @@ class MediaPlugin(Plugin):
|
||||||
self.media_dirs.add(self.download_dir)
|
self.media_dirs.add(self.download_dir)
|
||||||
|
|
||||||
self._videos_queue = []
|
self._videos_queue = []
|
||||||
self._streaming_port = streaming_port
|
|
||||||
self._streaming_proc = None
|
|
||||||
self._streaming_started = threading.Event()
|
|
||||||
self._streaming_ended = threading.Event()
|
|
||||||
|
|
||||||
def _get_resource(self, resource):
|
def _get_resource(self, resource):
|
||||||
"""
|
"""
|
||||||
|
@ -354,78 +339,62 @@ class MediaPlugin(Plugin):
|
||||||
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def start_streaming(self, media, port=None):
|
def start_streaming(self, media):
|
||||||
"""
|
"""
|
||||||
Starts streaming local media over the specified HTTP port.
|
Starts streaming local media over the specified HTTP port.
|
||||||
The stream will be available to HTTP clients on
|
The stream will be available to HTTP clients on
|
||||||
`http://{this-ip}:{port}/media
|
`http://{this-ip}:{http_backend_port}/media/<media_id>`
|
||||||
|
|
||||||
:param media: Media to stream
|
:param media: Media to stream
|
||||||
|
:type media: str
|
||||||
|
|
||||||
|
:returns: dict containing the streaming URL.Example::
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "0123456abcdef.mp4",
|
||||||
|
"source": "file:///mnt/media/movies/movie.mp4",
|
||||||
|
"mime_type": "video/mp4",
|
||||||
|
"url": "http://192.168.1.2:8008/media/0123456abcdef.mp4"
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
if self._streaming_proc:
|
import requests
|
||||||
self.logger.info('A streaming process is already running, ' +
|
|
||||||
'terminating it first')
|
|
||||||
self.stop_streaming()
|
|
||||||
|
|
||||||
if port is None:
|
http = get_backend('http')
|
||||||
port = self._streaming_port
|
if not http:
|
||||||
|
self.logger.warning('Unable to stream {}: HTTP backend unavailable'.
|
||||||
self._streaming_started.clear()
|
format(media))
|
||||||
self._streaming_ended.clear()
|
|
||||||
self._streaming_proc = subprocess.Popen(
|
|
||||||
[self._local_stream_bin, media, str(port)],
|
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
|
||||||
)
|
|
||||||
|
|
||||||
threading.Thread(target=self._streaming_process_monitor(media)).start()
|
|
||||||
url = 'http://{}:{}/media'.format(get_ip_or_hostname(),
|
|
||||||
self._streaming_port)
|
|
||||||
|
|
||||||
self.logger.info('Starting streaming {} on {}'.format(media, url))
|
|
||||||
self._streaming_started.wait()
|
|
||||||
self.logger.info('Started streaming {} on {}'.format(media, url))
|
|
||||||
return { 'url': url }
|
|
||||||
|
|
||||||
@action
|
|
||||||
def stop_streaming(self):
|
|
||||||
if not self._streaming_proc:
|
|
||||||
self.logger.info('No streaming process found')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self._streaming_proc.terminate()
|
self.logger.info('Starting streaming {}'.format(media))
|
||||||
self._streaming_proc.wait()
|
response = requests.put('{url}/media'.format(url=http.local_base_url),
|
||||||
try: self._streaming_proc.kill()
|
json = { 'source': media })
|
||||||
except: pass
|
|
||||||
self._streaming_proc = None
|
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
self.logger.warning('Unable to start streaming: {}'.
|
||||||
|
format(response.text or response.reason))
|
||||||
|
return
|
||||||
|
|
||||||
def _streaming_process_monitor(self, media):
|
return response.json()
|
||||||
def _thread():
|
|
||||||
if not self._streaming_proc:
|
|
||||||
return
|
|
||||||
|
|
||||||
while True:
|
@action
|
||||||
if not self._streaming_proc or not \
|
def stop_streaming(self, media_id):
|
||||||
is_process_alive(self._streaming_proc.pid):
|
import requests
|
||||||
break
|
|
||||||
|
|
||||||
line = self._streaming_proc.stdout.readline().decode().strip()
|
http = get_backend('http')
|
||||||
if not line:
|
if not http:
|
||||||
continue
|
self.logger.warning('Cannot unregister {}: HTTP backend unavailable'.
|
||||||
|
format(media_id))
|
||||||
|
return
|
||||||
|
|
||||||
if line.startswith('Listening on'):
|
response = requests.delete('{url}/media/{id}'.
|
||||||
self._streaming_started.set()
|
format(url=http.local_base_url, id=media_id))
|
||||||
break
|
|
||||||
|
|
||||||
self.logger.info('Message from streaming service: {}'.format(line))
|
if not response.ok:
|
||||||
|
self.logger.warning('Unable to unregister media_id {}: {}'.format(
|
||||||
|
media_id, response.reason))
|
||||||
|
return
|
||||||
|
|
||||||
self._streaming_proc.wait()
|
return response.json()
|
||||||
try: self.stop_streaming()
|
|
||||||
except: pass
|
|
||||||
self._streaming_ended.set()
|
|
||||||
self.logger.info('Streaming service terminated')
|
|
||||||
|
|
||||||
return _thread
|
|
||||||
|
|
||||||
|
|
||||||
def _youtube_search_api(self, query):
|
def _youtube_search_api(self, query):
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
|
||||||
|
|
||||||
if [ -d "$HOME/node_modules" ]; then
|
|
||||||
export NODE_PATH=$HOME/node_modules:$NODE_PATH
|
|
||||||
fi
|
|
||||||
|
|
||||||
file=$1
|
|
||||||
port=
|
|
||||||
[ ! -z "$2" ] && port=$2
|
|
||||||
|
|
||||||
node $DIR/localstream.js "$file" $port
|
|
||||||
|
|
|
@ -1,71 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
// Requires:
|
|
||||||
// - express (`npm install express`)
|
|
||||||
// - mime-types (`npm install mime-types`)
|
|
||||||
|
|
||||||
const express = require('express')
|
|
||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
const process = require('process')
|
|
||||||
const mime = require('mime-types')
|
|
||||||
const app = express()
|
|
||||||
|
|
||||||
function parseArgv() {
|
|
||||||
let file = undefined
|
|
||||||
let port = 8989
|
|
||||||
|
|
||||||
if (process.argv.length < 3) {
|
|
||||||
throw Error(`Usage: ${process.argv[0]} ${process.argv[1]} <media_file> [port=${port}]`)
|
|
||||||
}
|
|
||||||
|
|
||||||
file = process.argv[2]
|
|
||||||
|
|
||||||
if (process.argv.length > 3) {
|
|
||||||
port = parseInt(process.argv[3])
|
|
||||||
}
|
|
||||||
|
|
||||||
return { file: file, port: port }
|
|
||||||
}
|
|
||||||
|
|
||||||
let args = parseArgv()
|
|
||||||
|
|
||||||
app.get('/media', function(req, res) {
|
|
||||||
const path = args.file
|
|
||||||
const ext = args.file.split('.').pop()
|
|
||||||
const stat = fs.statSync(path)
|
|
||||||
const fileSize = stat.size
|
|
||||||
const range = req.headers.range
|
|
||||||
const mimeType = mime.lookup(ext)
|
|
||||||
|
|
||||||
if (range) {
|
|
||||||
const parts = range.replace(/bytes=/, "").split("-")
|
|
||||||
const start = parseInt(parts[0], 10)
|
|
||||||
const end = parts[1]
|
|
||||||
? parseInt(parts[1], 10)
|
|
||||||
: fileSize-1
|
|
||||||
|
|
||||||
const chunksize = (end-start)+1
|
|
||||||
const file = fs.createReadStream(path, {start, end})
|
|
||||||
const head = {
|
|
||||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
||||||
'Accept-Ranges': 'bytes',
|
|
||||||
'Content-Length': chunksize,
|
|
||||||
'Content-Type': mimeType,
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(206, head)
|
|
||||||
file.pipe(res)
|
|
||||||
} else {
|
|
||||||
const head = {
|
|
||||||
'Content-Length': fileSize,
|
|
||||||
'Content-Type': mimeType,
|
|
||||||
}
|
|
||||||
res.writeHead(200, head)
|
|
||||||
fs.createReadStream(path).pipe(res)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.listen(args.port, function () {
|
|
||||||
console.log(`Listening on port ${args.port}`)
|
|
||||||
})
|
|
|
@ -1,5 +1,4 @@
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import pychromecast
|
import pychromecast
|
||||||
|
|
||||||
|
@ -181,9 +180,6 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
player='chromecast',
|
player='chromecast',
|
||||||
**player_args)
|
**player_args)
|
||||||
|
|
||||||
if resource.startswith('file://'):
|
|
||||||
resource = resource[len('file://'):]
|
|
||||||
|
|
||||||
if not content_type:
|
if not content_type:
|
||||||
content_type = get_mime_type(resource)
|
content_type = get_mime_type(resource)
|
||||||
|
|
||||||
|
@ -191,8 +187,10 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
raise RuntimeError('content_type required to process media {}'.
|
raise RuntimeError('content_type required to process media {}'.
|
||||||
format(resource))
|
format(resource))
|
||||||
|
|
||||||
if os.path.isfile(resource):
|
if not resource.startswith('http://') and \
|
||||||
|
not resource.startswith('https://'):
|
||||||
resource = self.start_streaming(resource).output['url']
|
resource = self.start_streaming(resource).output['url']
|
||||||
|
self.logger.info('HTTP media stream started on {}'.format(resource))
|
||||||
|
|
||||||
self.logger.info('Playing {} on {}'.format(resource, chromecast))
|
self.logger.info('Playing {} on {}'.format(resource, chromecast))
|
||||||
|
|
||||||
|
|
3
setup.py
3
setup.py
|
@ -49,8 +49,7 @@ setup(
|
||||||
'platydock=platypush.platydock:main',
|
'platydock=platypush.platydock:main',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
scripts = ['bin/platyvenv', 'platypush/plugins/media/bin/localstream.js',
|
scripts = ['bin/platyvenv'],
|
||||||
'platypush/plugins/media/bin/localstream'],
|
|
||||||
# data_files = [
|
# data_files = [
|
||||||
# ('/etc/platypush', ['platypush/config.example.yaml'])
|
# ('/etc/platypush', ['platypush/config.example.yaml'])
|
||||||
# ],
|
# ],
|
||||||
|
|
Loading…
Reference in a new issue