forked from platypush/platypush
[#422] Enabled support for yt-dlp mux+transcoding in media plugins
Reviewed-on: platypush/platypush#423
This commit is contained in:
parent
ca5853cbab
commit
5080caa38e
49 changed files with 2220 additions and 1684 deletions
|
@ -1,6 +0,0 @@
|
||||||
``media.omxplayer``
|
|
||||||
=====================================
|
|
||||||
|
|
||||||
.. automodule:: platypush.plugins.media.omxplayer
|
|
||||||
:members:
|
|
||||||
|
|
|
@ -77,7 +77,6 @@ Plugins
|
||||||
platypush/plugins/media.kodi.rst
|
platypush/plugins/media.kodi.rst
|
||||||
platypush/plugins/media.mplayer.rst
|
platypush/plugins/media.mplayer.rst
|
||||||
platypush/plugins/media.mpv.rst
|
platypush/plugins/media.mpv.rst
|
||||||
platypush/plugins/media.omxplayer.rst
|
|
||||||
platypush/plugins/media.plex.rst
|
platypush/plugins/media.plex.rst
|
||||||
platypush/plugins/media.subtitles.rst
|
platypush/plugins/media.subtitles.rst
|
||||||
platypush/plugins/media.vlc.rst
|
platypush/plugins/media.vlc.rst
|
||||||
|
|
|
@ -25,10 +25,15 @@ def load_media_map() -> MediaMap:
|
||||||
logger().warning('Could not load media map: %s', e)
|
logger().warning('Could not load media map: %s', e)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
return {
|
parsed_map = {}
|
||||||
media_id: MediaHandler.build(**media_info)
|
for media_id, media_info in media_map.items():
|
||||||
for media_id, media_info in media_map.items()
|
try:
|
||||||
}
|
parsed_map[media_id] = MediaHandler.build(**media_info)
|
||||||
|
except Exception as e:
|
||||||
|
logger().debug('Could not load media %s: %s', media_id, e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return parsed_map
|
||||||
|
|
||||||
|
|
||||||
def save_media_map(new_map: MediaMap):
|
def save_media_map(new_map: MediaMap):
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from typing import Generator, Optional
|
from typing import Generator, Optional
|
||||||
|
|
||||||
from platypush.message import JSONAble
|
from platypush.message import JSONAble
|
||||||
|
@ -57,9 +56,6 @@ class MediaHandler(JSONAble, ABC):
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
errors[hndl_class.__name__] = str(e)
|
errors[hndl_class.__name__] = str(e)
|
||||||
|
|
||||||
if os.path.exists(source):
|
|
||||||
source = f'file://{source}'
|
|
||||||
|
|
||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
f'The source {source} has no handlers associated. Errors: {errors}'
|
f'The source {source} has no handlers associated. Errors: {errors}'
|
||||||
)
|
)
|
||||||
|
|
|
@ -15,6 +15,9 @@ class FileHandler(MediaHandler):
|
||||||
prefix_handlers = ['file://']
|
prefix_handlers = ['file://']
|
||||||
|
|
||||||
def __init__(self, source, *args, **kwargs):
|
def __init__(self, source, *args, **kwargs):
|
||||||
|
if isinstance(source, str) and os.path.exists(source):
|
||||||
|
source = f'file://{source}'
|
||||||
|
|
||||||
super().__init__(source, *args, **kwargs)
|
super().__init__(source, *args, **kwargs)
|
||||||
|
|
||||||
self.path = os.path.abspath(
|
self.path = os.path.abspath(
|
||||||
|
|
|
@ -70,6 +70,7 @@ class RedisBus(Bus):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = msg.get('data', b'').decode('utf-8')
|
data = msg.get('data', b'').decode('utf-8')
|
||||||
|
logger.debug('Received message on the Redis bus: %r', data)
|
||||||
parsed_msg = Message.build(data)
|
parsed_msg = Message.build(data)
|
||||||
if parsed_msg and self.on_message:
|
if parsed_msg and self.on_message:
|
||||||
self.on_message(parsed_msg)
|
self.on_message(parsed_msg)
|
||||||
|
|
|
@ -29,10 +29,10 @@ class Pipeline:
|
||||||
|
|
||||||
self.bus = self.pipeline.get_bus()
|
self.bus = self.pipeline.get_bus()
|
||||||
self.bus.add_signal_watch()
|
self.bus.add_signal_watch()
|
||||||
self.bus.connect('message::eos', self.on_eos)
|
self.bus.connect('message', self.on_message)
|
||||||
self.bus.connect('message::error', self.on_error)
|
|
||||||
self.data_ready = threading.Event()
|
self.data_ready = threading.Event()
|
||||||
self.data = None
|
self.data = None
|
||||||
|
self._gst_state = Gst.State.NULL
|
||||||
|
|
||||||
def add(self, element_name: str, *args, **props):
|
def add(self, element_name: str, *args, **props):
|
||||||
el = Gst.ElementFactory.make(element_name, *args)
|
el = Gst.ElementFactory.make(element_name, *args)
|
||||||
|
@ -94,6 +94,28 @@ class Pipeline:
|
||||||
assert self.source, 'No source initialized'
|
assert self.source, 'No source initialized'
|
||||||
self.source.set_property('volume', volume)
|
self.source.set_property('volume', volume)
|
||||||
|
|
||||||
|
def _msg_handler(self, message) -> bool:
|
||||||
|
from gi.repository import Gst # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
if message.type == Gst.MessageType.EOS:
|
||||||
|
self.on_eos()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if message.type == Gst.MessageType.ERROR:
|
||||||
|
err, debug = message.parse_error()
|
||||||
|
self.on_error(err, debug)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if message.type == Gst.MessageType.STATE_CHANGED:
|
||||||
|
old_state, new_state, _ = message.parse_state_changed()[:3]
|
||||||
|
self.on_state_changed(old_state, new_state)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False # The message was not handled
|
||||||
|
|
||||||
|
def on_message(self, _, message, *__):
|
||||||
|
self._msg_handler(message)
|
||||||
|
|
||||||
def on_buffer(self, sink):
|
def on_buffer(self, sink):
|
||||||
sample = GstApp.AppSink.pull_sample(sink)
|
sample = GstApp.AppSink.pull_sample(sink)
|
||||||
buffer = sample.get_buffer()
|
buffer = sample.get_buffer()
|
||||||
|
@ -106,6 +128,29 @@ class Pipeline:
|
||||||
self.logger.info('End of stream event received')
|
self.logger.info('End of stream event received')
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
|
def on_state_changed(self, old_state, new_state):
|
||||||
|
from gi.repository import Gst # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
if (
|
||||||
|
old_state == new_state
|
||||||
|
or new_state == self._gst_state
|
||||||
|
or old_state != self._gst_state
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._gst_state = new_state
|
||||||
|
|
||||||
|
if new_state == Gst.State.PLAYING:
|
||||||
|
self.on_play()
|
||||||
|
elif new_state == Gst.State.PAUSED:
|
||||||
|
self.on_pause()
|
||||||
|
|
||||||
|
def on_play(self):
|
||||||
|
self.logger.debug('GStreamer playback started')
|
||||||
|
|
||||||
|
def on_pause(self):
|
||||||
|
self.logger.debug('GStreamer playback paused')
|
||||||
|
|
||||||
def on_error(self, _, msg):
|
def on_error(self, _, msg):
|
||||||
self.logger.warning('GStreamer pipeline error: %s', msg.parse_error())
|
self.logger.warning('GStreamer pipeline error: %s', msg.parse_error())
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
@ -7,11 +6,11 @@ import time
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Any
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
from platypush.config import Config
|
from platypush.config import Config
|
||||||
from platypush.message import Message
|
from platypush.message import Message
|
||||||
from platypush.utils import get_event_class_by_type
|
from platypush.utils import get_event_class_by_type, get_plugin_name_by_class
|
||||||
|
|
||||||
logger = logging.getLogger('platypush')
|
logger = logging.getLogger('platypush')
|
||||||
|
|
||||||
|
@ -261,7 +260,7 @@ class Event(Message):
|
||||||
"""
|
"""
|
||||||
Converts the event into a dictionary
|
Converts the event into a dictionary
|
||||||
"""
|
"""
|
||||||
args = copy.deepcopy(self.args)
|
args = dict(deepcopy(self.args))
|
||||||
flatten(args)
|
flatten(args)
|
||||||
return {
|
return {
|
||||||
'type': 'event',
|
'type': 'event',
|
||||||
|
@ -277,7 +276,7 @@ class Event(Message):
|
||||||
Overrides the str() operator and converts
|
Overrides the str() operator and converts
|
||||||
the message into a UTF-8 JSON string
|
the message into a UTF-8 JSON string
|
||||||
"""
|
"""
|
||||||
args = copy.deepcopy(self.args)
|
args = deepcopy(self.args)
|
||||||
flatten(args)
|
flatten(args)
|
||||||
return json.dumps(self.as_dict(), cls=self.Encoder)
|
return json.dumps(self.as_dict(), cls=self.Encoder)
|
||||||
|
|
||||||
|
@ -297,6 +296,43 @@ class EventMatchResult:
|
||||||
parsed_args: dict = field(default_factory=dict)
|
parsed_args: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def deepcopy(
|
||||||
|
args: Union[dict, list], _out: Optional[Union[dict, list]] = None
|
||||||
|
) -> Union[dict, list]:
|
||||||
|
"""
|
||||||
|
Workaround implementation of deepcopy that doesn't raise exceptions
|
||||||
|
on non-pickeable objects.
|
||||||
|
"""
|
||||||
|
from platypush.plugins import Plugin
|
||||||
|
|
||||||
|
if _out is None:
|
||||||
|
_out = {} if isinstance(args, dict) else []
|
||||||
|
|
||||||
|
if isinstance(args, list):
|
||||||
|
_out = [None] * len(args)
|
||||||
|
for i, v in enumerate(args):
|
||||||
|
if isinstance(v, dict):
|
||||||
|
_out[i] = deepcopy(v)
|
||||||
|
elif isinstance(v, (list, tuple, set)):
|
||||||
|
_out[i] = deepcopy(list(v))
|
||||||
|
elif isinstance(v, Plugin):
|
||||||
|
_out[i] = get_plugin_name_by_class(v.__class__)
|
||||||
|
else:
|
||||||
|
_out[i] = v
|
||||||
|
elif isinstance(args, dict):
|
||||||
|
for k, v in args.items():
|
||||||
|
if isinstance(v, dict):
|
||||||
|
_out[k] = deepcopy(v)
|
||||||
|
elif isinstance(v, (list, tuple, set)):
|
||||||
|
_out[k] = deepcopy(list(v))
|
||||||
|
elif isinstance(v, Plugin):
|
||||||
|
_out[k] = get_plugin_name_by_class(v.__class__)
|
||||||
|
else:
|
||||||
|
_out[k] = v
|
||||||
|
|
||||||
|
return _out
|
||||||
|
|
||||||
|
|
||||||
def flatten(args):
|
def flatten(args):
|
||||||
"""
|
"""
|
||||||
Flatten a nested dictionary for string serialization.
|
Flatten a nested dictionary for string serialization.
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
import functools
|
|
||||||
import inspect
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
import queue
|
import queue
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
@ -13,6 +9,7 @@ from typing import (
|
||||||
Iterable,
|
Iterable,
|
||||||
List,
|
List,
|
||||||
Optional,
|
Optional,
|
||||||
|
Sequence,
|
||||||
Tuple,
|
Tuple,
|
||||||
Type,
|
Type,
|
||||||
Union,
|
Union,
|
||||||
|
@ -24,16 +21,18 @@ from platypush.config import Config
|
||||||
from platypush.context import get_plugin, get_backend
|
from platypush.context import get_plugin, get_backend
|
||||||
from platypush.message.event.media import MediaEvent
|
from platypush.message.event.media import MediaEvent
|
||||||
from platypush.plugins import RunnablePlugin, action
|
from platypush.plugins import RunnablePlugin, action
|
||||||
from platypush.utils import get_default_downloads_dir, get_plugin_name_by_class
|
from platypush.utils import (
|
||||||
|
get_default_downloads_dir,
|
||||||
from ._download import (
|
get_mime_type,
|
||||||
DownloadState,
|
get_plugin_name_by_class,
|
||||||
DownloadThread,
|
|
||||||
FileDownloadThread,
|
|
||||||
YouTubeDownloadThread,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ._constants import audio_extensions, video_extensions
|
||||||
|
from ._model import DownloadState, PlayerState
|
||||||
from ._resource import MediaResource
|
from ._resource import MediaResource
|
||||||
from ._state import PlayerState
|
from ._resource.downloaders import DownloadThread, MediaResourceDownloader, downloaders
|
||||||
|
from ._resource.parsers import MediaResourceParser, parsers
|
||||||
|
from ._search import MediaSearcher, searchers
|
||||||
|
|
||||||
|
|
||||||
class MediaPlugin(RunnablePlugin, ABC):
|
class MediaPlugin(RunnablePlugin, ABC):
|
||||||
|
@ -51,97 +50,14 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
'This method must be implemented in a derived class'
|
'This method must be implemented in a derived class'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Supported audio extensions
|
audio_extensions = audio_extensions
|
||||||
audio_extensions = {
|
video_extensions = video_extensions
|
||||||
'3gp',
|
|
||||||
'aa',
|
|
||||||
'aac',
|
|
||||||
'aax',
|
|
||||||
'act',
|
|
||||||
'aiff',
|
|
||||||
'amr',
|
|
||||||
'ape',
|
|
||||||
'au',
|
|
||||||
'awb',
|
|
||||||
'dct',
|
|
||||||
'dss',
|
|
||||||
'dvf',
|
|
||||||
'flac',
|
|
||||||
'gsm',
|
|
||||||
'iklax',
|
|
||||||
'ivs',
|
|
||||||
'm4a',
|
|
||||||
'm4b',
|
|
||||||
'm4p',
|
|
||||||
'mmf',
|
|
||||||
'mp3',
|
|
||||||
'mpc',
|
|
||||||
'msv',
|
|
||||||
'nmf',
|
|
||||||
'nsf',
|
|
||||||
'ogg,',
|
|
||||||
'opus',
|
|
||||||
'ra,',
|
|
||||||
'raw',
|
|
||||||
'sln',
|
|
||||||
'tta',
|
|
||||||
'vox',
|
|
||||||
'wav',
|
|
||||||
'wma',
|
|
||||||
'wv',
|
|
||||||
'webm',
|
|
||||||
'8svx',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Supported video extensions
|
|
||||||
video_extensions = {
|
|
||||||
'webm',
|
|
||||||
'mkv',
|
|
||||||
'flv',
|
|
||||||
'vob',
|
|
||||||
'ogv',
|
|
||||||
'ogg',
|
|
||||||
'drc',
|
|
||||||
'gif',
|
|
||||||
'gifv',
|
|
||||||
'mng',
|
|
||||||
'avi',
|
|
||||||
'mts',
|
|
||||||
'm2ts',
|
|
||||||
'mov',
|
|
||||||
'qt',
|
|
||||||
'wmv',
|
|
||||||
'yuv',
|
|
||||||
'rm',
|
|
||||||
'rmvb',
|
|
||||||
'asf',
|
|
||||||
'amv',
|
|
||||||
'mp4',
|
|
||||||
'm4p',
|
|
||||||
'm4v',
|
|
||||||
'mpg',
|
|
||||||
'mp2',
|
|
||||||
'mpeg',
|
|
||||||
'mpe',
|
|
||||||
'mpv',
|
|
||||||
'm2v',
|
|
||||||
'svi',
|
|
||||||
'3gp',
|
|
||||||
'3g2',
|
|
||||||
'mxf',
|
|
||||||
'roq',
|
|
||||||
'nsv',
|
|
||||||
'f4v',
|
|
||||||
'f4p',
|
|
||||||
'f4a',
|
|
||||||
'f4b',
|
|
||||||
}
|
|
||||||
|
|
||||||
supported_media_plugins = [
|
supported_media_plugins = [
|
||||||
|
'media.vlc',
|
||||||
|
'media.mpv',
|
||||||
'media.mplayer',
|
'media.mplayer',
|
||||||
'media.omxplayer',
|
'media.omxplayer',
|
||||||
'media.mpv',
|
|
||||||
'media.vlc',
|
|
||||||
'media.chromecast',
|
'media.chromecast',
|
||||||
'media.gstreamer',
|
'media.gstreamer',
|
||||||
]
|
]
|
||||||
|
@ -156,9 +72,13 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
env: Optional[Dict[str, str]] = None,
|
env: Optional[Dict[str, str]] = None,
|
||||||
volume: Optional[Union[float, int]] = None,
|
volume: Optional[Union[float, int]] = None,
|
||||||
torrent_plugin: str = 'torrent',
|
torrent_plugin: str = 'torrent',
|
||||||
youtube_format: Optional[str] = None,
|
youtube_format: Optional[str] = 'bv[height<=?1080]+ba/bv+ba',
|
||||||
|
youtube_audio_format: Optional[str] = 'ba',
|
||||||
youtube_dl: str = 'yt-dlp',
|
youtube_dl: str = 'yt-dlp',
|
||||||
merge_output_format: str = 'mp4',
|
merge_output_format: str = 'mp4',
|
||||||
|
cache_dir: Optional[str] = None,
|
||||||
|
cache_streams: bool = False,
|
||||||
|
ytdl_args: Optional[Sequence[str]] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
@ -187,6 +107,9 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
``bestvideo[height<=?1080][ext=mp4]+bestaudio`` - select the best
|
``bestvideo[height<=?1080][ext=mp4]+bestaudio`` - select the best
|
||||||
mp4 video with a resolution <= 1080p, and the best audio format.
|
mp4 video with a resolution <= 1080p, and the best audio format.
|
||||||
|
|
||||||
|
:param youtube_audio_format: Select the preferred audio format for
|
||||||
|
YouTube videos downloaded only for audio. Default: ``bestaudio``.
|
||||||
|
|
||||||
:param youtube_dl: Path to the ``youtube-dl`` executable, used to
|
:param youtube_dl: Path to the ``youtube-dl`` executable, used to
|
||||||
extract information from YouTube videos and other media platforms.
|
extract information from YouTube videos and other media platforms.
|
||||||
Default: ``yt-dlp``. The default has changed from ``youtube-dl`` to
|
Default: ``yt-dlp``. The default has changed from ``youtube-dl`` to
|
||||||
|
@ -197,12 +120,29 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
and the upstream media contains both audio and video to be merged,
|
and the upstream media contains both audio and video to be merged,
|
||||||
this can be used to specify the format of the output container -
|
this can be used to specify the format of the output container -
|
||||||
e.g. ``mp4``, ``mkv``, ``avi``, ``flv``. Default: ``mp4``.
|
e.g. ``mp4``, ``mkv``, ``avi``, ``flv``. Default: ``mp4``.
|
||||||
|
|
||||||
|
:param cache_dir: Directory where the media cache will be stored. If not
|
||||||
|
specified, the cache will be stored in the default cache directory
|
||||||
|
(usually ``~/.cache/platypush/media/<media_plugin>``).
|
||||||
|
|
||||||
|
:param cache_streams: If set to True, streams transcoded via yt-dlp or
|
||||||
|
ffmpeg will be cached in ``cache_dir`` directory. If not set
|
||||||
|
(default), then streams will be played directly via memory pipe.
|
||||||
|
You may want to set this to True if you have a slow network, or if
|
||||||
|
you want to play media at high quality, even though the start time
|
||||||
|
may be delayed. If set to False, the media will start playing as
|
||||||
|
soon as the stream is ready, but the quality may be lower,
|
||||||
|
especially at the beginning, and seeking may not be supported.
|
||||||
|
|
||||||
|
:param ytdl_args: Additional arguments to pass to the youtube-dl
|
||||||
|
executable. Default: None.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
if media_dirs is None:
|
if media_dirs is None:
|
||||||
media_dirs = []
|
media_dirs = []
|
||||||
|
|
||||||
player = None
|
player = None
|
||||||
player_config = {}
|
player_config = {}
|
||||||
self._download_threads: Dict[Tuple[str, str], DownloadThread] = {}
|
self._download_threads: Dict[Tuple[str, str], DownloadThread] = {}
|
||||||
|
@ -230,6 +170,7 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
self.registered_actions.add(act)
|
self.registered_actions.add(act)
|
||||||
|
|
||||||
self._env = env or {}
|
self._env = env or {}
|
||||||
|
self.cache_streams = cache_streams
|
||||||
self.media_dirs = set(
|
self.media_dirs = set(
|
||||||
filter(
|
filter(
|
||||||
os.path.isdir,
|
os.path.isdir,
|
||||||
|
@ -245,7 +186,18 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
os.makedirs(self.download_dir, exist_ok=True)
|
self.cache_dir = os.path.abspath(
|
||||||
|
os.path.expanduser(cache_dir)
|
||||||
|
if cache_dir
|
||||||
|
else os.path.join(
|
||||||
|
Config.get_cachedir(),
|
||||||
|
'media',
|
||||||
|
get_plugin_name_by_class(self.__class__),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pathlib.Path(self.cache_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
pathlib.Path(self.download_dir).mkdir(parents=True, exist_ok=True)
|
||||||
self._ytdl = youtube_dl
|
self._ytdl = youtube_dl
|
||||||
self.media_dirs.add(self.download_dir)
|
self.media_dirs.add(self.download_dir)
|
||||||
self.volume = volume
|
self.volume = volume
|
||||||
|
@ -253,62 +205,31 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
self._youtube_proc = None
|
self._youtube_proc = None
|
||||||
self.torrent_plugin = torrent_plugin
|
self.torrent_plugin = torrent_plugin
|
||||||
self.youtube_format = youtube_format
|
self.youtube_format = youtube_format
|
||||||
|
self.youtube_audio_format = youtube_audio_format
|
||||||
self.merge_output_format = merge_output_format
|
self.merge_output_format = merge_output_format
|
||||||
|
self.ytdl_args = ytdl_args or []
|
||||||
self._latest_resource: Optional[MediaResource] = None
|
self._latest_resource: Optional[MediaResource] = None
|
||||||
|
|
||||||
@staticmethod
|
self._parsers: Dict[Type[MediaResourceParser], MediaResourceParser] = {
|
||||||
def _torrent_event_handler(evt_queue):
|
parser: parser(self) for parser in parsers
|
||||||
def handler(event):
|
}
|
||||||
# More than 5% of the torrent has been downloaded
|
|
||||||
if event.args.get('progress', 0) > 5 and event.args.get('files'):
|
|
||||||
evt_queue.put(event.args['files'])
|
|
||||||
|
|
||||||
return handler
|
self._downloaders: Dict[
|
||||||
|
Type[MediaResourceDownloader], MediaResourceDownloader
|
||||||
|
] = {downloader: downloader(self) for downloader in downloaders}
|
||||||
|
|
||||||
def get_extractors(self):
|
self._searchers: Dict[Type[MediaSearcher], MediaSearcher] = {
|
||||||
try:
|
searcher: searcher(dirs=self.media_dirs, media_plugin=self)
|
||||||
from yt_dlp.extractor import _extractors # type: ignore
|
for searcher in searchers
|
||||||
except ImportError:
|
}
|
||||||
self.logger.debug('yt_dlp not installed')
|
|
||||||
return
|
|
||||||
|
|
||||||
for _, obj_type in inspect.getmembers(_extractors):
|
def _get_resource(
|
||||||
if (
|
self,
|
||||||
inspect.isclass(obj_type)
|
resource: str,
|
||||||
and isinstance(getattr(obj_type, "_VALID_URL", None), str)
|
metadata: Optional[dict] = None,
|
||||||
and obj_type.__name__ != "GenericIE"
|
only_audio: bool = False,
|
||||||
):
|
**_,
|
||||||
yield obj_type
|
):
|
||||||
|
|
||||||
def _is_youtube_resource(self, resource: str):
|
|
||||||
return any(
|
|
||||||
re.search(getattr(extractor, '_VALID_URL', '^$'), resource)
|
|
||||||
for extractor in self.get_extractors()
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_youtube_best_thumbnail(self, info: Dict[str, dict]):
|
|
||||||
thumbnails = info.get('thumbnails', {})
|
|
||||||
if not thumbnails:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Preferred resolution
|
|
||||||
for res in ((640, 480), (480, 360), (320, 240)):
|
|
||||||
thumb = next(
|
|
||||||
(
|
|
||||||
thumb
|
|
||||||
for thumb in thumbnails
|
|
||||||
if thumb.get('width') == res[0] and thumb.get('height') == res[1]
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if thumb:
|
|
||||||
return thumb.get('url')
|
|
||||||
|
|
||||||
# Default fallback (best quality)
|
|
||||||
return info.get('thumbnail')
|
|
||||||
|
|
||||||
def _get_resource(self, resource: str):
|
|
||||||
"""
|
"""
|
||||||
:param resource: Resource to play/parse. Supported types:
|
:param resource: Resource to play/parse. Supported types:
|
||||||
|
|
||||||
|
@ -319,72 +240,19 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if resource.startswith('file://'):
|
for parser in self._parsers.values():
|
||||||
path = resource[len('file://') :]
|
media_resource = parser.parse(resource, only_audio=only_audio)
|
||||||
assert os.path.isfile(path), f'File {path} not found'
|
if media_resource:
|
||||||
self._latest_resource = MediaResource(
|
for k, v in (metadata or {}).items():
|
||||||
resource=resource,
|
setattr(media_resource, k, v)
|
||||||
url=resource,
|
|
||||||
title=os.path.basename(resource),
|
|
||||||
filename=os.path.basename(resource),
|
|
||||||
)
|
|
||||||
elif self._is_youtube_resource(resource):
|
|
||||||
info = self._get_youtube_info(resource)
|
|
||||||
if info:
|
|
||||||
url = info.get('url')
|
|
||||||
if url:
|
|
||||||
resource = url
|
|
||||||
self._latest_resource = MediaResource(
|
|
||||||
resource=resource,
|
|
||||||
url=resource,
|
|
||||||
title=info.get('title'),
|
|
||||||
description=info.get('description'),
|
|
||||||
filename=info.get('filename'),
|
|
||||||
image=info.get('thumbnail'),
|
|
||||||
duration=float(info.get('duration') or 0) or None,
|
|
||||||
channel=info.get('channel'),
|
|
||||||
channel_url=info.get('channel_url'),
|
|
||||||
resolution=info.get('resolution'),
|
|
||||||
type=info.get('extractor'),
|
|
||||||
)
|
|
||||||
elif resource.startswith('magnet:?'):
|
|
||||||
self.logger.info(
|
|
||||||
'Downloading torrent %s to %s', resource, self.download_dir
|
|
||||||
)
|
|
||||||
torrents = get_plugin(self.torrent_plugin)
|
|
||||||
assert torrents, f'{self.torrent_plugin} plugin not configured'
|
|
||||||
|
|
||||||
evt_queue = queue.Queue()
|
return media_resource
|
||||||
torrents.download(
|
|
||||||
resource,
|
|
||||||
download_dir=self.download_dir,
|
|
||||||
_async=True,
|
|
||||||
is_media=True,
|
|
||||||
event_hndl=self._torrent_event_handler(evt_queue),
|
|
||||||
)
|
|
||||||
|
|
||||||
resources = [f for f in evt_queue.get()] # noqa: C416,R1721
|
raise AssertionError(f'Unknown media resource: {resource}')
|
||||||
|
|
||||||
if resources:
|
|
||||||
self._videos_queue = sorted(resources)
|
|
||||||
resource = self._videos_queue.pop(0)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f'No media file found in torrent {resource}')
|
|
||||||
|
|
||||||
assert resource, 'Unable to find any compatible media resource'
|
|
||||||
return resource
|
|
||||||
|
|
||||||
def _stop_torrent(self):
|
|
||||||
try:
|
|
||||||
torrents = get_plugin(self.torrent_plugin)
|
|
||||||
assert torrents, f'{self.torrent_plugin} plugin not configured'
|
|
||||||
torrents.quit()
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning('Could not stop torrent plugin: %s', e)
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def play(self, resource, **kwargs):
|
def play(self, resource: str, **kwargs):
|
||||||
raise self._NOT_IMPLEMENTED_ERR
|
raise self._NOT_IMPLEMENTED_ERR
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -567,43 +435,45 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
return thread
|
return thread
|
||||||
|
|
||||||
def _get_search_handler_by_type(self, search_type: str):
|
def _get_search_handler_by_type(self, search_type: str):
|
||||||
if search_type == 'file':
|
searcher = next(
|
||||||
from .search import LocalMediaSearcher
|
iter(filter(lambda s: s.supports(search_type), self._searchers.values())),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
return LocalMediaSearcher(self.media_dirs, media_plugin=self)
|
if not searcher:
|
||||||
if search_type == 'torrent':
|
self.logger.warning('Unsupported search type: %s', search_type)
|
||||||
from .search import TorrentMediaSearcher
|
return None
|
||||||
|
|
||||||
return TorrentMediaSearcher(media_plugin=self)
|
return searcher
|
||||||
if search_type == 'youtube':
|
|
||||||
from .search import YoutubeMediaSearcher
|
|
||||||
|
|
||||||
return YoutubeMediaSearcher(media_plugin=self)
|
|
||||||
if search_type == 'plex':
|
|
||||||
from .search import PlexMediaSearcher
|
|
||||||
|
|
||||||
return PlexMediaSearcher(media_plugin=self)
|
|
||||||
if search_type == 'jellyfin':
|
|
||||||
from .search import JellyfinMediaSearcher
|
|
||||||
|
|
||||||
return JellyfinMediaSearcher(media_plugin=self)
|
|
||||||
|
|
||||||
self.logger.warning('Unsupported search type: %s', search_type)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_video_file(cls, filename: str):
|
def is_video_file(cls, filename: str):
|
||||||
return filename.lower().split('.')[-1] in cls.video_extensions
|
if filename.lower().split('.')[-1] in cls.video_extensions:
|
||||||
|
return True
|
||||||
|
|
||||||
|
mime_type = get_mime_type(filename)
|
||||||
|
return bool(mime_type and mime_type.startswith('video/'))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_audio_file(cls, filename: str):
|
def is_audio_file(cls, filename: str):
|
||||||
return filename.lower().split('.')[-1] in cls.audio_extensions
|
if filename.lower().split('.')[-1] in cls.audio_extensions:
|
||||||
|
return True
|
||||||
|
|
||||||
def _get_info(self, resource: str):
|
mime_type = get_mime_type(filename)
|
||||||
if self._is_youtube_resource(resource):
|
return bool(mime_type and mime_type.startswith('audio/'))
|
||||||
return self.get_youtube_info(resource)
|
|
||||||
|
|
||||||
return {'url': resource}
|
@classmethod
|
||||||
|
def is_media_file(cls, file: str) -> bool:
|
||||||
|
if file.split('.')[-1].lower() in cls.video_extensions.union(
|
||||||
|
cls.audio_extensions
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
mime_type = get_mime_type(file)
|
||||||
|
return bool(
|
||||||
|
mime_type
|
||||||
|
and (mime_type.startswith('video/') or mime_type.startswith('audio/'))
|
||||||
|
)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def start_streaming(
|
def start_streaming(
|
||||||
|
@ -660,109 +530,14 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
assert response.ok, response.text or response.reason
|
assert response.ok, response.text or response.reason
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
def _get_youtube_info(self, url, youtube_format: Optional[str] = None):
|
|
||||||
ytdl_cmd = [
|
|
||||||
self._ytdl,
|
|
||||||
*(
|
|
||||||
['-f', youtube_format or self.youtube_format]
|
|
||||||
if youtube_format or self.youtube_format
|
|
||||||
else []
|
|
||||||
),
|
|
||||||
'-j',
|
|
||||||
'-g',
|
|
||||||
url,
|
|
||||||
]
|
|
||||||
|
|
||||||
self.logger.info('Executing command %s', ' '.join(ytdl_cmd))
|
|
||||||
with subprocess.Popen(ytdl_cmd, stdout=subprocess.PIPE) as ytdl:
|
|
||||||
output = ytdl.communicate()[0].decode().strip()
|
|
||||||
ytdl.wait()
|
|
||||||
|
|
||||||
self.logger.debug('yt-dlp output: %s', output)
|
|
||||||
lines = output.split('\n')
|
|
||||||
|
|
||||||
if not lines:
|
|
||||||
self.logger.warning('No output from yt-dlp')
|
|
||||||
return None
|
|
||||||
|
|
||||||
stream_url = lines[1] if len(lines) > 2 else lines[0]
|
|
||||||
info = lines[-1]
|
|
||||||
return {
|
|
||||||
**json.loads(info),
|
|
||||||
'url': stream_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_youtube_id(url: str) -> Optional[str]:
|
|
||||||
patterns = [
|
|
||||||
re.compile(pattern)
|
|
||||||
for pattern in [
|
|
||||||
r'https?://www.youtube.com/watch\?v=([^&#]+)',
|
|
||||||
r'https?://youtube.com/watch\?v=([^&#]+)',
|
|
||||||
r'https?://youtu.be/([^&#/]+)',
|
|
||||||
r'youtube:video:([^&#:])',
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in patterns:
|
|
||||||
m = pattern.search(url)
|
|
||||||
if m:
|
|
||||||
return m.group(1)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@action
|
|
||||||
def get_youtube_info(self, url):
|
|
||||||
# Legacy conversion for Mopidy YouTube URIs
|
|
||||||
m = re.match('youtube:video:(.*)', url)
|
|
||||||
if m:
|
|
||||||
url = f'https://www.youtube.com/watch?v={m.group(1)}'
|
|
||||||
|
|
||||||
with subprocess.Popen([self._ytdl, '-j', url], stdout=subprocess.PIPE) as proc:
|
|
||||||
if proc.stdout is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return proc.stdout.read().decode("utf-8", "strict")[:-1]
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def get_info(self, resource: str):
|
def get_info(self, resource: str):
|
||||||
return self._get_info(resource)
|
for parser in self._parsers.values():
|
||||||
|
info = parser.parse(resource)
|
||||||
|
if info:
|
||||||
|
return info.to_dict()
|
||||||
|
|
||||||
@action
|
return {'url': resource}
|
||||||
def get_media_file_duration(self, filename):
|
|
||||||
"""
|
|
||||||
Get the duration of a media file in seconds. Requires ffmpeg
|
|
||||||
"""
|
|
||||||
|
|
||||||
if filename.startswith('file://'):
|
|
||||||
filename = filename[7:]
|
|
||||||
|
|
||||||
with subprocess.Popen(
|
|
||||||
["ffprobe", filename], stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
|
||||||
) as result:
|
|
||||||
if not result.stdout:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
return functools.reduce(
|
|
||||||
lambda t, t_i: t + t_i,
|
|
||||||
[
|
|
||||||
float(t) * pow(60, i)
|
|
||||||
for (i, t) in enumerate(
|
|
||||||
re.search(
|
|
||||||
r'^Duration:\s*([^,]+)',
|
|
||||||
[
|
|
||||||
x.decode()
|
|
||||||
for x in result.stdout.readlines()
|
|
||||||
if "Duration" in x.decode()
|
|
||||||
]
|
|
||||||
.pop()
|
|
||||||
.strip(),
|
|
||||||
)
|
|
||||||
.group(1) # type: ignore
|
|
||||||
.split(':')[::-1]
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def download(
|
def download(
|
||||||
|
@ -774,6 +549,7 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
sync: bool = False,
|
sync: bool = False,
|
||||||
only_audio: bool = False,
|
only_audio: bool = False,
|
||||||
youtube_format: Optional[str] = None,
|
youtube_format: Optional[str] = None,
|
||||||
|
youtube_audio_format: Optional[str] = None,
|
||||||
merge_output_format: Optional[str] = None,
|
merge_output_format: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
@ -802,25 +578,46 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
:param only_audio: If set to True, only the audio track will be downloaded
|
:param only_audio: If set to True, only the audio track will be downloaded
|
||||||
(only supported for yt-dlp-compatible URLs for now).
|
(only supported for yt-dlp-compatible URLs for now).
|
||||||
:param youtube_format: Override the default ``youtube_format`` setting.
|
:param youtube_format: Override the default ``youtube_format`` setting.
|
||||||
|
:param youtube_audio_format: Override the default ``youtube_audio_format``
|
||||||
:param merge_output_format: Override the default
|
:param merge_output_format: Override the default
|
||||||
``merge_output_format`` setting.
|
``merge_output_format`` setting.
|
||||||
:return: The absolute path to the downloaded file.
|
:return: The absolute path to the downloaded file.
|
||||||
"""
|
"""
|
||||||
path = self._get_download_path(
|
dl_thread = None
|
||||||
url, directory=directory, filename=filename, youtube_format=youtube_format
|
resource = self._get_resource(url, only_audio=only_audio)
|
||||||
)
|
|
||||||
|
|
||||||
if self._is_youtube_resource(url):
|
if filename:
|
||||||
dl_thread = self._download_youtube_url(
|
path = os.path.expanduser(filename)
|
||||||
url, path, youtube_format=youtube_format, only_audio=only_audio
|
elif resource.filename:
|
||||||
)
|
path = resource.filename
|
||||||
else:
|
else:
|
||||||
if only_audio:
|
path = os.path.basename(resource.url)
|
||||||
self.logger.warning(
|
|
||||||
'Only audio download is not supported for non-YouTube URLs'
|
if not os.path.isabs(path):
|
||||||
|
directory = os.path.expanduser(directory or self.download_dir)
|
||||||
|
path = os.path.join(directory, path)
|
||||||
|
|
||||||
|
for downloader in self._downloaders.values():
|
||||||
|
if downloader.supports(resource):
|
||||||
|
if only_audio and not downloader.supports_only_audio():
|
||||||
|
self.logger.warning(
|
||||||
|
'Only audio download is not supported for this resource'
|
||||||
|
)
|
||||||
|
|
||||||
|
dl_thread = downloader.download(
|
||||||
|
resource=resource,
|
||||||
|
path=path,
|
||||||
|
timeout=timeout,
|
||||||
|
only_audio=only_audio,
|
||||||
|
youtube_format=youtube_format or self.youtube_format,
|
||||||
|
youtube_audio_format=youtube_audio_format
|
||||||
|
or self.youtube_audio_format,
|
||||||
|
merge_output_format=merge_output_format,
|
||||||
)
|
)
|
||||||
|
|
||||||
dl_thread = self._download_url(url, path, timeout=timeout)
|
break
|
||||||
|
|
||||||
|
assert dl_thread, f'No downloader found for resource {url}'
|
||||||
|
|
||||||
if sync:
|
if sync:
|
||||||
dl_thread.join()
|
dl_thread.join()
|
||||||
|
@ -934,90 +731,10 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
assert threads, f'No matching downloads found for [url={url}, path={path}]'
|
assert threads, f'No matching downloads found for [url={url}, path={path}]'
|
||||||
return threads
|
return threads
|
||||||
|
|
||||||
def _get_download_path(
|
def on_download_start(self, thread: DownloadThread):
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
directory: Optional[str] = None,
|
|
||||||
filename: Optional[str] = None,
|
|
||||||
youtube_format: Optional[str] = None,
|
|
||||||
) -> str:
|
|
||||||
if not directory:
|
|
||||||
directory = self.download_dir
|
|
||||||
|
|
||||||
directory = os.path.expanduser(directory)
|
|
||||||
youtube_format = youtube_format or self.youtube_format
|
|
||||||
|
|
||||||
if self._is_youtube_resource(url):
|
|
||||||
with subprocess.Popen(
|
|
||||||
[
|
|
||||||
self._ytdl,
|
|
||||||
*(
|
|
||||||
[
|
|
||||||
'-f',
|
|
||||||
youtube_format,
|
|
||||||
]
|
|
||||||
if youtube_format
|
|
||||||
else []
|
|
||||||
),
|
|
||||||
*(
|
|
||||||
['--merge-output-format', self.merge_output_format]
|
|
||||||
if self.merge_output_format
|
|
||||||
else []
|
|
||||||
),
|
|
||||||
'-O',
|
|
||||||
'%(title)s.%(ext)s',
|
|
||||||
url,
|
|
||||||
],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
) as proc:
|
|
||||||
assert proc.stdout, 'yt-dlp stdout is None'
|
|
||||||
filename = proc.stdout.read().decode()[:-1]
|
|
||||||
|
|
||||||
if not filename:
|
|
||||||
filename = url.split('/')[-1]
|
|
||||||
|
|
||||||
return os.path.join(directory, filename)
|
|
||||||
|
|
||||||
def _download_url(self, url: str, path: str, timeout: int) -> FileDownloadThread:
|
|
||||||
download_thread = FileDownloadThread(
|
|
||||||
url=url,
|
|
||||||
path=path,
|
|
||||||
timeout=timeout,
|
|
||||||
on_start=self._on_download_start,
|
|
||||||
post_event=self._post_event,
|
|
||||||
stop_event=self._should_stop,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._start_download(download_thread)
|
|
||||||
return download_thread
|
|
||||||
|
|
||||||
def _download_youtube_url(
|
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
path: str,
|
|
||||||
youtube_format: Optional[str] = None,
|
|
||||||
merge_output_format: Optional[str] = None,
|
|
||||||
only_audio: bool = False,
|
|
||||||
) -> YouTubeDownloadThread:
|
|
||||||
download_thread = YouTubeDownloadThread(
|
|
||||||
url=url,
|
|
||||||
path=path,
|
|
||||||
ytdl=self._ytdl,
|
|
||||||
only_audio=only_audio,
|
|
||||||
youtube_format=youtube_format or self.youtube_format,
|
|
||||||
merge_output_format=merge_output_format or self.merge_output_format,
|
|
||||||
on_start=self._on_download_start,
|
|
||||||
post_event=self._post_event,
|
|
||||||
stop_event=self._should_stop,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._start_download(download_thread)
|
|
||||||
return download_thread
|
|
||||||
|
|
||||||
def _on_download_start(self, thread: DownloadThread):
|
|
||||||
self._download_threads[thread.url, thread.path] = thread
|
self._download_threads[thread.url, thread.path] = thread
|
||||||
|
|
||||||
def _start_download(self, thread: DownloadThread):
|
def start_download(self, thread: DownloadThread):
|
||||||
if (thread.url, thread.path) in self._download_threads:
|
if (thread.url, thread.path) in self._download_threads:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
'A download of %s to %s is already in progress', thread.url, thread.path
|
'A download of %s to %s is already in progress', thread.url, thread.path
|
||||||
|
@ -1026,7 +743,7 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
|
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def _post_event(self, event_type: Type[MediaEvent], **kwargs):
|
def post_event(self, event_type: Type[MediaEvent], **kwargs):
|
||||||
evt = event_type(
|
evt = event_type(
|
||||||
player=get_plugin_name_by_class(self.__class__), plugin=self, **kwargs
|
player=get_plugin_name_by_class(self.__class__), plugin=self, **kwargs
|
||||||
)
|
)
|
||||||
|
@ -1054,6 +771,14 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
f.write(content)
|
f.write(content)
|
||||||
return f.name
|
return f.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_local_media(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_local_pipe(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
def main(self):
|
def main(self):
|
||||||
self.wait_stop()
|
self.wait_stop()
|
||||||
|
|
||||||
|
@ -1061,6 +786,8 @@ class MediaPlugin(RunnablePlugin, ABC):
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'DownloadState',
|
'DownloadState',
|
||||||
'MediaPlugin',
|
'MediaPlugin',
|
||||||
|
'MediaResource',
|
||||||
|
'MediaSearcher',
|
||||||
'PlayerState',
|
'PlayerState',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
89
platypush/plugins/media/_constants.py
Normal file
89
platypush/plugins/media/_constants.py
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
# Supported audio extensions
|
||||||
|
audio_extensions = {
|
||||||
|
'3gp',
|
||||||
|
'aa',
|
||||||
|
'aac',
|
||||||
|
'aax',
|
||||||
|
'act',
|
||||||
|
'aiff',
|
||||||
|
'amr',
|
||||||
|
'ape',
|
||||||
|
'au',
|
||||||
|
'awb',
|
||||||
|
'dct',
|
||||||
|
'dss',
|
||||||
|
'dvf',
|
||||||
|
'flac',
|
||||||
|
'gsm',
|
||||||
|
'iklax',
|
||||||
|
'ivs',
|
||||||
|
'm4a',
|
||||||
|
'm4b',
|
||||||
|
'm4p',
|
||||||
|
'mmf',
|
||||||
|
'mp3',
|
||||||
|
'mpc',
|
||||||
|
'msv',
|
||||||
|
'nmf',
|
||||||
|
'nsf',
|
||||||
|
'ogg,',
|
||||||
|
'opus',
|
||||||
|
'ra,',
|
||||||
|
'raw',
|
||||||
|
'sln',
|
||||||
|
'tta',
|
||||||
|
'vox',
|
||||||
|
'wav',
|
||||||
|
'wma',
|
||||||
|
'wv',
|
||||||
|
'webm',
|
||||||
|
'8svx',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Supported video extensions
|
||||||
|
video_extensions = {
|
||||||
|
'webm',
|
||||||
|
'mkv',
|
||||||
|
'flv',
|
||||||
|
'vob',
|
||||||
|
'ogv',
|
||||||
|
'ogg',
|
||||||
|
'drc',
|
||||||
|
'gif',
|
||||||
|
'gifv',
|
||||||
|
'mng',
|
||||||
|
'avi',
|
||||||
|
'mts',
|
||||||
|
'm2ts',
|
||||||
|
'mov',
|
||||||
|
'qt',
|
||||||
|
'wmv',
|
||||||
|
'yuv',
|
||||||
|
'rm',
|
||||||
|
'rmvb',
|
||||||
|
'asf',
|
||||||
|
'amv',
|
||||||
|
'mp4',
|
||||||
|
'm4p',
|
||||||
|
'm4v',
|
||||||
|
'mpg',
|
||||||
|
'mp2',
|
||||||
|
'mpeg',
|
||||||
|
'mpe',
|
||||||
|
'mpv',
|
||||||
|
'm2v',
|
||||||
|
'svi',
|
||||||
|
'3gp',
|
||||||
|
'3g2',
|
||||||
|
'mxf',
|
||||||
|
'roq',
|
||||||
|
'nsv',
|
||||||
|
'f4v',
|
||||||
|
'f4p',
|
||||||
|
'f4a',
|
||||||
|
'f4b',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
|
@ -1,354 +0,0 @@
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from contextlib import suppress
|
|
||||||
from enum import Enum
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Any, Callable, Optional, Type
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from platypush.message.event.media import (
|
|
||||||
MediaDownloadCancelledEvent,
|
|
||||||
MediaDownloadClearEvent,
|
|
||||||
MediaDownloadCompletedEvent,
|
|
||||||
MediaDownloadErrorEvent,
|
|
||||||
MediaDownloadEvent,
|
|
||||||
MediaDownloadPausedEvent,
|
|
||||||
MediaDownloadProgressEvent,
|
|
||||||
MediaDownloadResumedEvent,
|
|
||||||
MediaDownloadStartedEvent,
|
|
||||||
)
|
|
||||||
|
|
||||||
from platypush.utils import wait_for_either
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadState(Enum):
|
|
||||||
"""
|
|
||||||
Enum that represents the status of a download.
|
|
||||||
"""
|
|
||||||
|
|
||||||
IDLE = 'idle'
|
|
||||||
STARTED = 'started'
|
|
||||||
DOWNLOADING = 'downloading'
|
|
||||||
PAUSED = 'paused'
|
|
||||||
COMPLETED = 'completed'
|
|
||||||
CANCELLED = 'cancelled'
|
|
||||||
ERROR = 'error'
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadThread(threading.Thread, ABC):
|
|
||||||
"""
|
|
||||||
Thread that downloads a URL to a file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_progress_update_interval = 1
|
|
||||||
""" Throttle the progress updates to this interval, in seconds. """
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
path: str,
|
|
||||||
url: str,
|
|
||||||
post_event: Callable,
|
|
||||||
size: Optional[int] = None,
|
|
||||||
timeout: Optional[int] = 10,
|
|
||||||
on_start: Callable[['DownloadThread'], None] = lambda _: None,
|
|
||||||
on_close: Callable[['DownloadThread'], None] = lambda _: None,
|
|
||||||
stop_event: Optional[threading.Event] = None,
|
|
||||||
):
|
|
||||||
super().__init__(name=f'DownloadThread-{path}')
|
|
||||||
self.path = path
|
|
||||||
self.url = url
|
|
||||||
self.size = size
|
|
||||||
self.timeout = timeout
|
|
||||||
self.state = DownloadState.IDLE
|
|
||||||
self.progress = None
|
|
||||||
self.started_at = None
|
|
||||||
self.ended_at = None
|
|
||||||
self._upstream_stop_event = stop_event or threading.Event()
|
|
||||||
self._stop_event = threading.Event()
|
|
||||||
self._post_event = post_event
|
|
||||||
self._on_start = on_start
|
|
||||||
self._on_close = on_close
|
|
||||||
self._paused = threading.Event()
|
|
||||||
self._downloading = threading.Event()
|
|
||||||
self._last_progress_update_time = 0
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def should_stop(self) -> bool:
|
|
||||||
return self._stop_event.is_set() or self._upstream_stop_event.is_set()
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _run(self) -> bool:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def pause(self):
|
|
||||||
self.state = DownloadState.PAUSED
|
|
||||||
self._paused.set()
|
|
||||||
self._downloading.clear()
|
|
||||||
self.post_event(MediaDownloadPausedEvent)
|
|
||||||
|
|
||||||
def resume(self):
|
|
||||||
self.state = DownloadState.DOWNLOADING
|
|
||||||
self._paused.clear()
|
|
||||||
self._downloading.set()
|
|
||||||
self.post_event(MediaDownloadResumedEvent)
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
super().run()
|
|
||||||
interrupted = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.on_start()
|
|
||||||
interrupted = not self._run()
|
|
||||||
|
|
||||||
if interrupted:
|
|
||||||
self.state = DownloadState.CANCELLED
|
|
||||||
else:
|
|
||||||
self.state = DownloadState.COMPLETED
|
|
||||||
except Exception as e:
|
|
||||||
self.state = DownloadState.ERROR
|
|
||||||
self.post_event(MediaDownloadErrorEvent, error=str(e))
|
|
||||||
self.logger.warning('Error while downloading URL: %s', e)
|
|
||||||
finally:
|
|
||||||
self.on_close()
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
self.state = DownloadState.CANCELLED
|
|
||||||
self._stop_event.set()
|
|
||||||
self._downloading.clear()
|
|
||||||
|
|
||||||
def on_start(self):
|
|
||||||
self.state = DownloadState.STARTED
|
|
||||||
self.started_at = time.time()
|
|
||||||
self.post_event(MediaDownloadStartedEvent)
|
|
||||||
self._on_start(self)
|
|
||||||
|
|
||||||
def on_close(self):
|
|
||||||
self.ended_at = time.time()
|
|
||||||
if self.state == DownloadState.CANCELLED:
|
|
||||||
self.post_event(MediaDownloadCancelledEvent)
|
|
||||||
elif self.state == DownloadState.COMPLETED:
|
|
||||||
self.post_event(MediaDownloadCompletedEvent)
|
|
||||||
|
|
||||||
self._on_close(self)
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
if self.state not in (DownloadState.COMPLETED, DownloadState.CANCELLED):
|
|
||||||
self.logger.info(
|
|
||||||
'Download thread for %s is still active, stopping', self.url
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stop()
|
|
||||||
self.join(timeout=10)
|
|
||||||
|
|
||||||
self.post_event(MediaDownloadClearEvent)
|
|
||||||
|
|
||||||
def post_event(self, event_type: Type[MediaDownloadEvent], **kwargs):
|
|
||||||
kwargs = {
|
|
||||||
'resource': self.url,
|
|
||||||
'path': self.path,
|
|
||||||
'state': self.state.value,
|
|
||||||
'size': self.size,
|
|
||||||
'timeout': self.timeout,
|
|
||||||
'progress': self.progress,
|
|
||||||
'started_at': self.started_at,
|
|
||||||
'ended_at': self.ended_at,
|
|
||||||
**kwargs,
|
|
||||||
}
|
|
||||||
|
|
||||||
self._post_event(event_type, **kwargs)
|
|
||||||
|
|
||||||
def __setattr__(self, name: str, value: Optional[Any], /) -> None:
|
|
||||||
if name == 'progress' and value is not None:
|
|
||||||
if value < 0 or value > 100:
|
|
||||||
self.logger.debug('Invalid progress value:%s', value)
|
|
||||||
return
|
|
||||||
|
|
||||||
prev_progress = getattr(self, 'progress', None)
|
|
||||||
|
|
||||||
if prev_progress is None or (
|
|
||||||
int(prev_progress) != int(value)
|
|
||||||
and (
|
|
||||||
time.time() - self._last_progress_update_time
|
|
||||||
>= self._progress_update_interval
|
|
||||||
)
|
|
||||||
):
|
|
||||||
value = round(value, 2)
|
|
||||||
self._last_progress_update_time = time.time()
|
|
||||||
self.post_event(MediaDownloadProgressEvent, progress=value)
|
|
||||||
|
|
||||||
super().__setattr__(name, value)
|
|
||||||
|
|
||||||
|
|
||||||
class FileDownloadThread(DownloadThread):
|
|
||||||
"""
|
|
||||||
Thread that downloads a generic URL to a file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
interrupted = False
|
|
||||||
|
|
||||||
with requests.get(self.url, timeout=self.timeout, stream=True) as response:
|
|
||||||
response.raise_for_status()
|
|
||||||
self.size = int(response.headers.get('Content-Length', 0)) or None
|
|
||||||
|
|
||||||
with open(self.path, 'wb') as f:
|
|
||||||
self.on_start()
|
|
||||||
|
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
|
||||||
if not chunk or self.should_stop():
|
|
||||||
interrupted = self.should_stop()
|
|
||||||
if interrupted:
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
self.state = DownloadState.DOWNLOADING
|
|
||||||
f.write(chunk)
|
|
||||||
percent = f.tell() / self.size * 100 if self.size else 0
|
|
||||||
self.progress = percent
|
|
||||||
|
|
||||||
if self._paused.is_set():
|
|
||||||
wait_for_either(self._downloading, self._stop_event)
|
|
||||||
|
|
||||||
return not interrupted
|
|
||||||
|
|
||||||
|
|
||||||
class YouTubeDownloadThread(DownloadThread):
|
|
||||||
"""
|
|
||||||
Thread that downloads a YouTube URL to a file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*args,
|
|
||||||
ytdl: str,
|
|
||||||
youtube_format: Optional[str] = None,
|
|
||||||
merge_output_format: Optional[str] = None,
|
|
||||||
only_audio: bool = False,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self._ytdl = ytdl
|
|
||||||
self._youtube_format = youtube_format
|
|
||||||
self._merge_output_format = merge_output_format
|
|
||||||
self._only_audio = only_audio
|
|
||||||
self._proc = None
|
|
||||||
self._proc_lock = threading.Lock()
|
|
||||||
|
|
||||||
def _parse_progress(self, line: str):
|
|
||||||
try:
|
|
||||||
progress = json.loads(line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return
|
|
||||||
|
|
||||||
status = progress.get('status')
|
|
||||||
if not status:
|
|
||||||
return
|
|
||||||
|
|
||||||
if status == 'finished':
|
|
||||||
self.progress = 100
|
|
||||||
return
|
|
||||||
|
|
||||||
if status == 'paused':
|
|
||||||
self.state = DownloadState.PAUSED
|
|
||||||
elif status == 'downloading':
|
|
||||||
self.state = DownloadState.DOWNLOADING
|
|
||||||
|
|
||||||
self.size = int(progress.get('total_bytes_estimate', 0)) or self.size
|
|
||||||
if self.size:
|
|
||||||
downloaded = int(progress.get('downloaded_bytes', 0))
|
|
||||||
self.progress = (downloaded / self.size) * 100
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
ytdl_cmd = [
|
|
||||||
self._ytdl,
|
|
||||||
'--newline',
|
|
||||||
'--progress',
|
|
||||||
'--progress-delta',
|
|
||||||
str(self._progress_update_interval),
|
|
||||||
'--progress-template',
|
|
||||||
'%(progress)j',
|
|
||||||
*(['-x'] if self._only_audio else []),
|
|
||||||
*(['-f', self._youtube_format] if self._youtube_format else []),
|
|
||||||
*(
|
|
||||||
['--stream-output-format', self._merge_output_format]
|
|
||||||
if self._merge_output_format else []
|
|
||||||
),
|
|
||||||
self.url,
|
|
||||||
'-o',
|
|
||||||
self.path,
|
|
||||||
]
|
|
||||||
|
|
||||||
self.logger.info('Executing command %r', ytdl_cmd)
|
|
||||||
err = None
|
|
||||||
|
|
||||||
with subprocess.Popen(ytdl_cmd, stdout=subprocess.PIPE) as self._proc:
|
|
||||||
if self._proc.stdout:
|
|
||||||
for line in self._proc.stdout:
|
|
||||||
self.logger.debug(
|
|
||||||
'%s output: %s', self._ytdl, line.decode().strip()
|
|
||||||
)
|
|
||||||
|
|
||||||
self._parse_progress(line.decode())
|
|
||||||
|
|
||||||
if self.should_stop():
|
|
||||||
self.stop()
|
|
||||||
return self._proc.returncode == 0
|
|
||||||
|
|
||||||
if self._paused.is_set():
|
|
||||||
wait_for_either(self._downloading, self._stop_event)
|
|
||||||
|
|
||||||
if self._proc.returncode != 0:
|
|
||||||
err = self._proc.stderr.read().decode() if self._proc.stderr else None
|
|
||||||
raise RuntimeError(
|
|
||||||
f'{self._ytdl} failed with return code {self._proc.returncode}: {err}'
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def pause(self):
|
|
||||||
with self._proc_lock:
|
|
||||||
if self._proc:
|
|
||||||
self._proc.send_signal(signal.SIGSTOP)
|
|
||||||
|
|
||||||
super().pause()
|
|
||||||
|
|
||||||
def resume(self):
|
|
||||||
with self._proc_lock:
|
|
||||||
if self._proc:
|
|
||||||
self._proc.send_signal(signal.SIGCONT)
|
|
||||||
|
|
||||||
super().resume()
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
state = None
|
|
||||||
|
|
||||||
with suppress(IOError, OSError), self._proc_lock:
|
|
||||||
if self._proc:
|
|
||||||
if self._proc.poll() is None:
|
|
||||||
self._proc.terminate()
|
|
||||||
self._proc.wait(timeout=3)
|
|
||||||
if self._proc.returncode is None:
|
|
||||||
self._proc.kill()
|
|
||||||
|
|
||||||
state = DownloadState.CANCELLED
|
|
||||||
elif self._proc.returncode != 0:
|
|
||||||
state = DownloadState.ERROR
|
|
||||||
else:
|
|
||||||
state = DownloadState.COMPLETED
|
|
||||||
|
|
||||||
self._proc = None
|
|
||||||
|
|
||||||
super().stop()
|
|
||||||
|
|
||||||
if state:
|
|
||||||
self.state = state
|
|
||||||
|
|
||||||
def on_close(self):
|
|
||||||
self.stop()
|
|
||||||
super().on_close()
|
|
29
platypush/plugins/media/_model.py
Normal file
29
platypush/plugins/media/_model.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerState(enum.Enum):
|
||||||
|
"""
|
||||||
|
Models the possible states of a media player
|
||||||
|
"""
|
||||||
|
|
||||||
|
STOP = 'stop'
|
||||||
|
PLAY = 'play'
|
||||||
|
PAUSE = 'pause'
|
||||||
|
IDLE = 'idle'
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadState(enum.Enum):
|
||||||
|
"""
|
||||||
|
Enum that represents the status of a download.
|
||||||
|
"""
|
||||||
|
|
||||||
|
IDLE = 'idle'
|
||||||
|
STARTED = 'started'
|
||||||
|
DOWNLOADING = 'downloading'
|
||||||
|
PAUSED = 'paused'
|
||||||
|
COMPLETED = 'completed'
|
||||||
|
CANCELLED = 'cancelled'
|
||||||
|
ERROR = 'error'
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
|
@ -1,21 +0,0 @@
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MediaResource:
|
|
||||||
"""
|
|
||||||
Models a media resource
|
|
||||||
"""
|
|
||||||
|
|
||||||
resource: str
|
|
||||||
url: str
|
|
||||||
title: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
filename: Optional[str] = None
|
|
||||||
image: Optional[str] = None
|
|
||||||
duration: Optional[float] = None
|
|
||||||
channel: Optional[str] = None
|
|
||||||
channel_url: Optional[str] = None
|
|
||||||
type: Optional[str] = None
|
|
||||||
resolution: Optional[str] = None
|
|
12
platypush/plugins/media/_resource/__init__.py
Normal file
12
platypush/plugins/media/_resource/__init__.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
from ._base import MediaResource
|
||||||
|
from .file import FileMediaResource
|
||||||
|
from .http import HttpMediaResource
|
||||||
|
from .youtube import YoutubeMediaResource
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'FileMediaResource',
|
||||||
|
'HttpMediaResource',
|
||||||
|
'MediaResource',
|
||||||
|
'YoutubeMediaResource',
|
||||||
|
]
|
134
platypush/plugins/media/_resource/_base.py
Normal file
134
platypush/plugins/media/_resource/_base.py
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field, fields
|
||||||
|
from subprocess import Popen
|
||||||
|
from typing import IO, Iterable, Optional
|
||||||
|
|
||||||
|
from platypush.plugins import Plugin
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaResource(ABC):
|
||||||
|
"""
|
||||||
|
Models a generic media resource.
|
||||||
|
|
||||||
|
In this case resource/URL can be passed directly to the player.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resource: str
|
||||||
|
url: str
|
||||||
|
media_plugin: Plugin
|
||||||
|
fd: Optional[IO] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
ext: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
filename: Optional[str] = None
|
||||||
|
size: Optional[int] = None
|
||||||
|
image: Optional[str] = None
|
||||||
|
duration: Optional[float] = None
|
||||||
|
channel: Optional[str] = None
|
||||||
|
channel_id: Optional[str] = None
|
||||||
|
channel_url: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
width: Optional[int] = None
|
||||||
|
height: Optional[int] = None
|
||||||
|
resolution: Optional[str] = None
|
||||||
|
timestamp: Optional[float] = None
|
||||||
|
fps: Optional[float] = None
|
||||||
|
audio_channels: Optional[int] = None
|
||||||
|
view_count: Optional[int] = None
|
||||||
|
categories: Optional[Iterable[str]] = field(default_factory=list)
|
||||||
|
tags: Optional[Iterable[str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _logger(self):
|
||||||
|
return logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _media(self):
|
||||||
|
"""
|
||||||
|
This workaround is required to avoid circular imports.
|
||||||
|
"""
|
||||||
|
from platypush.plugins.media import MediaPlugin
|
||||||
|
|
||||||
|
assert isinstance(self.media_plugin, MediaPlugin)
|
||||||
|
return self.media_plugin
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def open(self, *_, **__) -> IO:
|
||||||
|
"""
|
||||||
|
Opens the media resource.
|
||||||
|
"""
|
||||||
|
if self.fd is not None:
|
||||||
|
try:
|
||||||
|
self.fd.seek(0)
|
||||||
|
except io.UnsupportedOperation:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return self.fd
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
Closes the media resource.
|
||||||
|
"""
|
||||||
|
if self.fd is not None:
|
||||||
|
self.fd.close()
|
||||||
|
self.fd = None
|
||||||
|
|
||||||
|
def __enter__(self) -> IO:
|
||||||
|
"""
|
||||||
|
Opens the media resource using a context manager.
|
||||||
|
"""
|
||||||
|
return self.open()
|
||||||
|
|
||||||
|
def __exit__(self, *_, **__) -> None:
|
||||||
|
"""
|
||||||
|
Closes the media resource using a context manager.
|
||||||
|
"""
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""
|
||||||
|
Converts the media resource to a dictionary ready to be serialized.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
f.name: getattr(self, f.name)
|
||||||
|
for f in fields(self)
|
||||||
|
if f.name not in ['media_plugin', 'fd']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PopenMediaResource(MediaResource, ABC):
|
||||||
|
"""
|
||||||
|
Models a media resource that is read from a Popen object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
proc: Optional[Popen] = None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
Closes the media resource.
|
||||||
|
"""
|
||||||
|
if self.proc is not None:
|
||||||
|
self.proc.terminate()
|
||||||
|
self.proc.wait(1)
|
||||||
|
if self.proc and self.proc.poll() is None:
|
||||||
|
self.proc.kill()
|
||||||
|
self.proc.wait(1)
|
||||||
|
|
||||||
|
self.proc = None
|
||||||
|
|
||||||
|
super().close()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""
|
||||||
|
Converts the media resource to a dictionary ready to be serialized.
|
||||||
|
"""
|
||||||
|
ret = super().to_dict()
|
||||||
|
ret.pop('proc', None)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
16
platypush/plugins/media/_resource/downloaders/__init__.py
Normal file
16
platypush/plugins/media/_resource/downloaders/__init__.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
from ._base import DownloadThread, MediaResourceDownloader
|
||||||
|
from .http import HttpResourceDownloader
|
||||||
|
from .youtube import YoutubeResourceDownloader
|
||||||
|
|
||||||
|
downloaders = [
|
||||||
|
YoutubeResourceDownloader,
|
||||||
|
HttpResourceDownloader,
|
||||||
|
]
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'DownloadThread',
|
||||||
|
'HttpResourceDownloader',
|
||||||
|
'MediaResourceDownloader',
|
||||||
|
'YoutubeResourceDownloader',
|
||||||
|
'downloaders',
|
||||||
|
]
|
208
platypush/plugins/media/_resource/downloaders/_base.py
Normal file
208
platypush/plugins/media/_resource/downloaders/_base.py
Normal file
|
@ -0,0 +1,208 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Callable, Optional, Type
|
||||||
|
|
||||||
|
from platypush.message.event.media import (
|
||||||
|
MediaDownloadCancelledEvent,
|
||||||
|
MediaDownloadClearEvent,
|
||||||
|
MediaDownloadCompletedEvent,
|
||||||
|
MediaDownloadErrorEvent,
|
||||||
|
MediaDownloadEvent,
|
||||||
|
MediaDownloadPausedEvent,
|
||||||
|
MediaDownloadProgressEvent,
|
||||||
|
MediaDownloadResumedEvent,
|
||||||
|
MediaDownloadStartedEvent,
|
||||||
|
)
|
||||||
|
from platypush.plugins.media._model import DownloadState
|
||||||
|
from platypush.plugins.media._resource import MediaResource
|
||||||
|
|
||||||
|
|
||||||
|
class MediaResourceDownloader(ABC):
|
||||||
|
"""
|
||||||
|
Base media resource downloader class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, media_plugin, *_, **__):
|
||||||
|
from platypush.plugins.media import MediaPlugin
|
||||||
|
|
||||||
|
self._media: MediaPlugin = media_plugin
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def download(
|
||||||
|
self, resource: MediaResource, path: Optional[str] = None, **_
|
||||||
|
) -> 'DownloadThread':
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_download_path(
|
||||||
|
self,
|
||||||
|
resource: MediaResource,
|
||||||
|
*_,
|
||||||
|
directory: Optional[str] = None,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
**__,
|
||||||
|
) -> str:
|
||||||
|
directory = (
|
||||||
|
os.path.expanduser(directory) if directory else self._media.download_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
filename = resource.filename or resource.url.split('/')[-1]
|
||||||
|
|
||||||
|
return os.path.join(directory, filename)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def supports(self, resource_type: MediaResource) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def supports_only_audio(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadThread(threading.Thread, ABC):
|
||||||
|
"""
|
||||||
|
Thread that downloads a URL to a file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_progress_update_interval = 1
|
||||||
|
""" Throttle the progress updates to this interval, in seconds. """
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
url: str,
|
||||||
|
post_event: Callable,
|
||||||
|
size: Optional[int] = None,
|
||||||
|
timeout: Optional[int] = 10,
|
||||||
|
on_start: Callable[['DownloadThread'], None] = lambda _: None,
|
||||||
|
on_close: Callable[['DownloadThread'], None] = lambda _: None,
|
||||||
|
stop_event: Optional[threading.Event] = None,
|
||||||
|
):
|
||||||
|
super().__init__(name=f'DownloadThread-{path}')
|
||||||
|
self.path = path
|
||||||
|
self.url = url
|
||||||
|
self.size = size
|
||||||
|
self.timeout = timeout
|
||||||
|
self.state = DownloadState.IDLE
|
||||||
|
self.progress = None
|
||||||
|
self.started_at = None
|
||||||
|
self.ended_at = None
|
||||||
|
self._upstream_stop_event = stop_event or threading.Event()
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._post_event = post_event
|
||||||
|
self._on_start = on_start
|
||||||
|
self._on_close = on_close
|
||||||
|
self._paused = threading.Event()
|
||||||
|
self._downloading = threading.Event()
|
||||||
|
self._last_progress_update_time = 0
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def should_stop(self) -> bool:
|
||||||
|
return self._stop_event.is_set() or self._upstream_stop_event.is_set()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _run(self) -> bool:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
self.state = DownloadState.PAUSED
|
||||||
|
self._paused.set()
|
||||||
|
self._downloading.clear()
|
||||||
|
self.post_event(MediaDownloadPausedEvent)
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
self.state = DownloadState.DOWNLOADING
|
||||||
|
self._paused.clear()
|
||||||
|
self._downloading.set()
|
||||||
|
self.post_event(MediaDownloadResumedEvent)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
super().run()
|
||||||
|
interrupted = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.on_start()
|
||||||
|
interrupted = not self._run()
|
||||||
|
|
||||||
|
if interrupted:
|
||||||
|
self.state = DownloadState.CANCELLED
|
||||||
|
else:
|
||||||
|
self.state = DownloadState.COMPLETED
|
||||||
|
except Exception as e:
|
||||||
|
self.state = DownloadState.ERROR
|
||||||
|
self.post_event(MediaDownloadErrorEvent, error=str(e))
|
||||||
|
self.logger.warning('Error while downloading URL: %s', e)
|
||||||
|
finally:
|
||||||
|
self.on_close()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.state = DownloadState.CANCELLED
|
||||||
|
self._stop_event.set()
|
||||||
|
self._downloading.clear()
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
self.state = DownloadState.STARTED
|
||||||
|
self.started_at = time.time()
|
||||||
|
self.post_event(MediaDownloadStartedEvent)
|
||||||
|
self._on_start(self)
|
||||||
|
|
||||||
|
def on_close(self):
|
||||||
|
self.ended_at = time.time()
|
||||||
|
if self.state == DownloadState.CANCELLED:
|
||||||
|
self.post_event(MediaDownloadCancelledEvent)
|
||||||
|
elif self.state == DownloadState.COMPLETED:
|
||||||
|
self.post_event(MediaDownloadCompletedEvent)
|
||||||
|
|
||||||
|
self._on_close(self)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
if self.state not in (DownloadState.COMPLETED, DownloadState.CANCELLED):
|
||||||
|
self.logger.info(
|
||||||
|
'Download thread for %s is still active, stopping', self.url
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stop()
|
||||||
|
self.join(timeout=10)
|
||||||
|
|
||||||
|
self.post_event(MediaDownloadClearEvent)
|
||||||
|
|
||||||
|
def post_event(self, event_type: Type[MediaDownloadEvent], **kwargs):
|
||||||
|
kwargs = {
|
||||||
|
'resource': self.url,
|
||||||
|
'path': self.path,
|
||||||
|
'state': self.state.value,
|
||||||
|
'size': self.size,
|
||||||
|
'timeout': self.timeout,
|
||||||
|
'progress': self.progress,
|
||||||
|
'started_at': self.started_at,
|
||||||
|
'ended_at': self.ended_at,
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
|
||||||
|
self._post_event(event_type, **kwargs)
|
||||||
|
|
||||||
|
def __setattr__(self, name: str, value: Optional[Any], /) -> None:
|
||||||
|
if name == 'progress' and value is not None:
|
||||||
|
if value < 0 or value > 100:
|
||||||
|
self.logger.debug('Invalid progress value:%s', value)
|
||||||
|
return
|
||||||
|
|
||||||
|
prev_progress = getattr(self, 'progress', None)
|
||||||
|
|
||||||
|
if prev_progress is None or (
|
||||||
|
int(prev_progress) != int(value)
|
||||||
|
and (
|
||||||
|
time.time() - self._last_progress_update_time
|
||||||
|
>= self._progress_update_interval
|
||||||
|
)
|
||||||
|
):
|
||||||
|
value = round(value, 2)
|
||||||
|
self._last_progress_update_time = time.time()
|
||||||
|
self.post_event(MediaDownloadProgressEvent, progress=value)
|
||||||
|
|
||||||
|
super().__setattr__(name, value)
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
73
platypush/plugins/media/_resource/downloaders/http.py
Normal file
73
platypush/plugins/media/_resource/downloaders/http.py
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
from typing import Optional
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from platypush.plugins.media._resource import HttpMediaResource, MediaResource
|
||||||
|
from platypush.utils import wait_for_either
|
||||||
|
|
||||||
|
from ._base import DownloadState, DownloadThread, MediaResourceDownloader
|
||||||
|
|
||||||
|
|
||||||
|
class HttpResourceDownloader(MediaResourceDownloader):
|
||||||
|
"""
|
||||||
|
Downloader for generic HTTP URLs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def supports(self, resource_type: MediaResource) -> bool: # type: ignore[override]
|
||||||
|
return isinstance(resource_type, HttpMediaResource)
|
||||||
|
|
||||||
|
def download( # type: ignore[override]
|
||||||
|
self,
|
||||||
|
resource: HttpMediaResource,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
timeout: Optional[int] = None,
|
||||||
|
**_
|
||||||
|
) -> 'HttpDownloadThread':
|
||||||
|
path = path or self.get_download_path(resource=resource)
|
||||||
|
download_thread = HttpDownloadThread(
|
||||||
|
url=resource.url,
|
||||||
|
path=path,
|
||||||
|
timeout=timeout,
|
||||||
|
on_start=self._media.on_download_start,
|
||||||
|
post_event=self._media.post_event,
|
||||||
|
stop_event=self._media._should_stop, # pylint: disable=protected-access
|
||||||
|
)
|
||||||
|
|
||||||
|
self._media.start_download(download_thread)
|
||||||
|
return download_thread
|
||||||
|
|
||||||
|
|
||||||
|
class HttpDownloadThread(DownloadThread):
|
||||||
|
"""
|
||||||
|
Thread that downloads a generic URL to a file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
interrupted = False
|
||||||
|
|
||||||
|
with requests.get(self.url, timeout=self.timeout, stream=True) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
self.size = int(response.headers.get('Content-Length', 0)) or None
|
||||||
|
|
||||||
|
with open(self.path, 'wb') as f:
|
||||||
|
self.on_start()
|
||||||
|
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
if not chunk or self.should_stop():
|
||||||
|
interrupted = self.should_stop()
|
||||||
|
if interrupted:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
self.state = DownloadState.DOWNLOADING
|
||||||
|
f.write(chunk)
|
||||||
|
percent = f.tell() / self.size * 100 if self.size else 0
|
||||||
|
self.progress = percent
|
||||||
|
|
||||||
|
if self._paused.is_set():
|
||||||
|
wait_for_either(self._downloading, self._stop_event)
|
||||||
|
|
||||||
|
return not interrupted
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
232
platypush/plugins/media/_resource/downloaders/youtube.py
Normal file
232
platypush/plugins/media/_resource/downloaders/youtube.py
Normal file
|
@ -0,0 +1,232 @@
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from platypush.plugins.media._model import DownloadState
|
||||||
|
from platypush.plugins.media._resource import MediaResource, YoutubeMediaResource
|
||||||
|
from platypush.utils import wait_for_either
|
||||||
|
|
||||||
|
from ._base import DownloadThread, MediaResourceDownloader
|
||||||
|
|
||||||
|
|
||||||
|
class YoutubeResourceDownloader(MediaResourceDownloader):
|
||||||
|
"""
|
||||||
|
Downloader for YouTube URLs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def supports(self, resource_type: MediaResource) -> bool:
|
||||||
|
return isinstance(resource_type, YoutubeMediaResource)
|
||||||
|
|
||||||
|
def download( # type: ignore[override]
|
||||||
|
self,
|
||||||
|
resource: YoutubeMediaResource,
|
||||||
|
path: str,
|
||||||
|
youtube_format: Optional[str] = None,
|
||||||
|
youtube_audio_format: Optional[str] = None,
|
||||||
|
merge_output_format: Optional[str] = None,
|
||||||
|
only_audio: bool = False,
|
||||||
|
**_,
|
||||||
|
) -> 'YouTubeDownloadThread':
|
||||||
|
path = self.get_download_path(
|
||||||
|
resource=resource, directory=path, youtube_format=youtube_format
|
||||||
|
)
|
||||||
|
|
||||||
|
download_thread = YouTubeDownloadThread(
|
||||||
|
url=resource.url,
|
||||||
|
path=path,
|
||||||
|
ytdl=self._media._ytdl, # pylint: disable=protected-access
|
||||||
|
only_audio=only_audio,
|
||||||
|
youtube_format=youtube_format or self._media.youtube_format,
|
||||||
|
youtube_audio_format=youtube_audio_format
|
||||||
|
or self._media.youtube_audio_format,
|
||||||
|
merge_output_format=merge_output_format or self._media.merge_output_format,
|
||||||
|
on_start=self._media.on_download_start,
|
||||||
|
post_event=self._media.post_event,
|
||||||
|
stop_event=self._media._should_stop, # pylint: disable=protected-access
|
||||||
|
)
|
||||||
|
|
||||||
|
self._media.start_download(download_thread)
|
||||||
|
return download_thread
|
||||||
|
|
||||||
|
def get_download_path(
|
||||||
|
self,
|
||||||
|
resource: YoutubeMediaResource,
|
||||||
|
*_,
|
||||||
|
directory: Optional[str] = None,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
youtube_format: Optional[str] = None,
|
||||||
|
**__,
|
||||||
|
) -> str:
|
||||||
|
youtube_format = youtube_format or self._media.youtube_format
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
filename = resource.filename
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
with subprocess.Popen(
|
||||||
|
[
|
||||||
|
self._media._ytdl, # pylint: disable=protected-access
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
'-f',
|
||||||
|
youtube_format,
|
||||||
|
]
|
||||||
|
if youtube_format
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
'-O',
|
||||||
|
'%(title)s.%(ext)s',
|
||||||
|
resource.url,
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
) as proc:
|
||||||
|
assert proc.stdout, 'yt-dlp stdout is None'
|
||||||
|
filename = proc.stdout.read().decode()[:-1]
|
||||||
|
|
||||||
|
return super().get_download_path(
|
||||||
|
resource, directory=directory, filename=filename
|
||||||
|
)
|
||||||
|
|
||||||
|
def supports_only_audio(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeDownloadThread(DownloadThread):
|
||||||
|
"""
|
||||||
|
Thread that downloads a YouTube URL to a file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*args,
|
||||||
|
ytdl: str,
|
||||||
|
youtube_format: Optional[str] = None,
|
||||||
|
youtube_audio_format: Optional[str] = None,
|
||||||
|
merge_output_format: Optional[str] = None,
|
||||||
|
only_audio: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._ytdl = ytdl
|
||||||
|
self._youtube_format = youtube_format
|
||||||
|
self._youtube_audio_format = youtube_audio_format
|
||||||
|
self._merge_output_format = merge_output_format
|
||||||
|
self._only_audio = only_audio
|
||||||
|
self._proc = None
|
||||||
|
self._proc_lock = threading.Lock()
|
||||||
|
|
||||||
|
def _parse_progress(self, line: str):
|
||||||
|
try:
|
||||||
|
progress = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
status = progress.get('status')
|
||||||
|
if not status:
|
||||||
|
return
|
||||||
|
|
||||||
|
if status == 'finished':
|
||||||
|
self.progress = 100
|
||||||
|
return
|
||||||
|
|
||||||
|
if status == 'paused':
|
||||||
|
self.state = DownloadState.PAUSED
|
||||||
|
elif status == 'downloading':
|
||||||
|
self.state = DownloadState.DOWNLOADING
|
||||||
|
|
||||||
|
self.size = int(progress.get('total_bytes_estimate', 0)) or self.size
|
||||||
|
if self.size:
|
||||||
|
downloaded = int(progress.get('downloaded_bytes', 0))
|
||||||
|
self.progress = (downloaded / self.size) * 100
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
youtube_format = (
|
||||||
|
self._youtube_audio_format if self._only_audio else self._youtube_format
|
||||||
|
)
|
||||||
|
|
||||||
|
ytdl_cmd = [
|
||||||
|
self._ytdl,
|
||||||
|
'--newline',
|
||||||
|
'--progress',
|
||||||
|
'--progress-delta',
|
||||||
|
str(self._progress_update_interval),
|
||||||
|
'--progress-template',
|
||||||
|
'%(progress)j',
|
||||||
|
*(['-x'] if self._only_audio else []),
|
||||||
|
*(['-f', youtube_format] if youtube_format else []),
|
||||||
|
self.url,
|
||||||
|
'-o',
|
||||||
|
self.path,
|
||||||
|
]
|
||||||
|
|
||||||
|
self.logger.info('Executing command %r', ytdl_cmd)
|
||||||
|
err = None
|
||||||
|
|
||||||
|
with subprocess.Popen(ytdl_cmd, stdout=subprocess.PIPE) as self._proc:
|
||||||
|
if self._proc.stdout:
|
||||||
|
for line in self._proc.stdout:
|
||||||
|
self.logger.debug(
|
||||||
|
'%s output: %s', self._ytdl, line.decode().strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._parse_progress(line.decode())
|
||||||
|
|
||||||
|
if self.should_stop():
|
||||||
|
self.stop()
|
||||||
|
return self._proc.returncode == 0
|
||||||
|
|
||||||
|
if self._paused.is_set():
|
||||||
|
wait_for_either(self._downloading, self._stop_event)
|
||||||
|
|
||||||
|
if self._proc.returncode != 0:
|
||||||
|
err = self._proc.stderr.read().decode() if self._proc.stderr else None
|
||||||
|
raise RuntimeError(
|
||||||
|
f'{self._ytdl} failed with return code {self._proc.returncode}: {err}'
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
with self._proc_lock:
|
||||||
|
if self._proc:
|
||||||
|
self._proc.send_signal(signal.SIGSTOP)
|
||||||
|
|
||||||
|
super().pause()
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
with self._proc_lock:
|
||||||
|
if self._proc:
|
||||||
|
self._proc.send_signal(signal.SIGCONT)
|
||||||
|
|
||||||
|
super().resume()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
state = None
|
||||||
|
|
||||||
|
with suppress(IOError, OSError), self._proc_lock:
|
||||||
|
if self._proc:
|
||||||
|
if self._proc.poll() is None:
|
||||||
|
self._proc.terminate()
|
||||||
|
self._proc.wait(timeout=3)
|
||||||
|
if self._proc.returncode is None:
|
||||||
|
self._proc.kill()
|
||||||
|
|
||||||
|
state = DownloadState.CANCELLED
|
||||||
|
elif self._proc.returncode != 0:
|
||||||
|
state = DownloadState.ERROR
|
||||||
|
else:
|
||||||
|
state = DownloadState.COMPLETED
|
||||||
|
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
if state:
|
||||||
|
self.state = state
|
||||||
|
|
||||||
|
def on_close(self):
|
||||||
|
self.stop()
|
||||||
|
super().on_close()
|
23
platypush/plugins/media/_resource/file.py
Normal file
23
platypush/plugins/media/_resource/file.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from ._base import MediaResource
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileMediaResource(MediaResource):
|
||||||
|
"""
|
||||||
|
Models a media resource that is read from a file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def open(self, *args, **kwargs) -> IO:
|
||||||
|
"""
|
||||||
|
Opens the media resource.
|
||||||
|
"""
|
||||||
|
if self.fd is None:
|
||||||
|
self.fd = open(self.resource, 'rb') # pylint: disable=consider-using-with
|
||||||
|
|
||||||
|
return super().open(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
19
platypush/plugins/media/_resource/http.py
Normal file
19
platypush/plugins/media/_resource/http.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from ._base import MediaResource
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HttpMediaResource(MediaResource):
|
||||||
|
"""
|
||||||
|
Models a media resource that is read from an HTTP response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def open(self, *args, **kwargs):
|
||||||
|
return super().open(*args, **kwargs)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
super().close()
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
26
platypush/plugins/media/_resource/parsers/__init__.py
Normal file
26
platypush/plugins/media/_resource/parsers/__init__.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
from ._base import MediaResourceParser
|
||||||
|
from .file import FileResourceParser
|
||||||
|
from .http import HttpResourceParser
|
||||||
|
from .torrent import TorrentResourceParser
|
||||||
|
from .youtube import YoutubeResourceParser
|
||||||
|
|
||||||
|
|
||||||
|
parsers = [
|
||||||
|
FileResourceParser,
|
||||||
|
YoutubeResourceParser,
|
||||||
|
TorrentResourceParser,
|
||||||
|
HttpResourceParser,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'MediaResourceParser',
|
||||||
|
'FileResourceParser',
|
||||||
|
'HttpResourceParser',
|
||||||
|
'TorrentResourceParser',
|
||||||
|
'YoutubeResourceParser',
|
||||||
|
'parsers',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
28
platypush/plugins/media/_resource/parsers/_base.py
Normal file
28
platypush/plugins/media/_resource/parsers/_base.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .. import MediaResource
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
class MediaResourceParser(ABC):
|
||||||
|
"""
|
||||||
|
Base class for media resource parsers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, media_plugin, *_, **__):
|
||||||
|
from platypush.plugins.media import MediaPlugin
|
||||||
|
|
||||||
|
self._media: MediaPlugin = media_plugin
|
||||||
|
self.logger = getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse(self, resource: str, *_, **__) -> Optional[MediaResource]:
|
||||||
|
"""
|
||||||
|
Parses a media resource and returns a MediaResource object.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
37
platypush/plugins/media/_resource/parsers/file.py
Normal file
37
platypush/plugins/media/_resource/parsers/file.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..._search.local.metadata import get_file_metadata
|
||||||
|
from .. import FileMediaResource
|
||||||
|
from ._base import MediaResourceParser
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
class FileResourceParser(MediaResourceParser):
|
||||||
|
"""
|
||||||
|
Parser for local file resources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def parse(self, resource: str, *_, **__) -> Optional[FileMediaResource]:
|
||||||
|
if resource.startswith('file://') or os.path.isfile(resource):
|
||||||
|
path = resource
|
||||||
|
if resource.startswith('file://'):
|
||||||
|
path = resource[len('file://') :]
|
||||||
|
|
||||||
|
assert os.path.isfile(path), f'File {path} not found'
|
||||||
|
metadata = get_file_metadata(path)
|
||||||
|
metadata['timestamp'] = metadata.pop('created_at', None)
|
||||||
|
|
||||||
|
return FileMediaResource(
|
||||||
|
resource=path,
|
||||||
|
url=f'file://{path}',
|
||||||
|
media_plugin=self._media,
|
||||||
|
title=os.path.basename(resource),
|
||||||
|
filename=os.path.basename(resource),
|
||||||
|
**metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
31
platypush/plugins/media/_resource/parsers/http.py
Normal file
31
platypush/plugins/media/_resource/parsers/http.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .. import HttpMediaResource
|
||||||
|
from ._base import MediaResourceParser
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
class HttpResourceParser(MediaResourceParser):
|
||||||
|
"""
|
||||||
|
Parser for HTTP resources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def parse(self, resource: str, *_, **__) -> Optional[HttpMediaResource]:
|
||||||
|
if resource.startswith('http://') or resource.startswith('https://'):
|
||||||
|
assert self._media.is_media_file(
|
||||||
|
resource
|
||||||
|
), f'Invalid media resource: {resource}'
|
||||||
|
|
||||||
|
return HttpMediaResource(
|
||||||
|
resource=resource,
|
||||||
|
url=resource,
|
||||||
|
media_plugin=self._media,
|
||||||
|
title=os.path.basename(resource),
|
||||||
|
filename=os.path.basename(resource),
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
62
platypush/plugins/media/_resource/parsers/torrent.py
Normal file
62
platypush/plugins/media/_resource/parsers/torrent.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from platypush.context import get_plugin
|
||||||
|
|
||||||
|
from .. import FileMediaResource
|
||||||
|
from ._base import MediaResourceParser
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
class TorrentResourceParser(MediaResourceParser):
|
||||||
|
"""
|
||||||
|
Parser for magnet links.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _torrent_event_handler(evt_queue):
|
||||||
|
def handler(event):
|
||||||
|
# More than 5% of the torrent has been downloaded
|
||||||
|
if event.args.get('progress', 0) > 5 and event.args.get('files'):
|
||||||
|
evt_queue.put(
|
||||||
|
[f for f in event.args['files'] if f.is_media_file(f.filename)]
|
||||||
|
)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
def parse(self, resource: str, *_, **__) -> Optional[FileMediaResource]:
|
||||||
|
if not resource.startswith('magnet:?'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
torrents = get_plugin(self._media.torrent_plugin)
|
||||||
|
assert torrents, f'{self._media.torrent_plugin} plugin not configured'
|
||||||
|
|
||||||
|
evt_queue = queue.Queue()
|
||||||
|
torrents.download(
|
||||||
|
resource,
|
||||||
|
download_dir=self._media.download_dir,
|
||||||
|
_async=True,
|
||||||
|
is_media=True,
|
||||||
|
event_hndl=self._torrent_event_handler(evt_queue),
|
||||||
|
)
|
||||||
|
|
||||||
|
resources = [f for f in evt_queue.get()] # noqa: C416,R1721
|
||||||
|
|
||||||
|
if resources:
|
||||||
|
self._media._videos_queue = videos_queue = sorted(resources)
|
||||||
|
resource = videos_queue.pop(0)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f'No media file found in torrent {resource}')
|
||||||
|
|
||||||
|
assert resource, 'Unable to find any compatible media resource'
|
||||||
|
return FileMediaResource(
|
||||||
|
resource=resource,
|
||||||
|
url=f'file://{resource}',
|
||||||
|
media_plugin=self._media,
|
||||||
|
title=os.path.basename(resource),
|
||||||
|
filename=os.path.basename(resource),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
119
platypush/plugins/media/_resource/parsers/youtube.py
Normal file
119
platypush/plugins/media/_resource/parsers/youtube.py
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import fields
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from .. import YoutubeMediaResource
|
||||||
|
from ._base import MediaResourceParser
|
||||||
|
|
||||||
|
|
||||||
|
class YoutubeResourceParser(MediaResourceParser):
|
||||||
|
"""
|
||||||
|
Parser for yt-dlp-compatible resources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_extractors():
|
||||||
|
try:
|
||||||
|
from yt_dlp.extractor import _extractors # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
# yt-dlp not installed
|
||||||
|
return
|
||||||
|
|
||||||
|
for _, obj_type in inspect.getmembers(_extractors):
|
||||||
|
if (
|
||||||
|
inspect.isclass(obj_type)
|
||||||
|
and isinstance(getattr(obj_type, "_VALID_URL", None), str)
|
||||||
|
and obj_type.__name__ != "GenericIE"
|
||||||
|
):
|
||||||
|
yield obj_type
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_youtube_resource(cls, resource: str):
|
||||||
|
return any(
|
||||||
|
re.search(getattr(extractor, '_VALID_URL', '^$'), resource)
|
||||||
|
for extractor in cls._get_extractors()
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_youtube_best_thumbnail(info: Dict[str, dict]):
|
||||||
|
thumbnails = info.get('thumbnails', {})
|
||||||
|
if not thumbnails:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Preferred resolution
|
||||||
|
for res in ((640, 480), (480, 360), (320, 240)):
|
||||||
|
thumb = next(
|
||||||
|
(
|
||||||
|
thumb
|
||||||
|
for thumb in thumbnails
|
||||||
|
if thumb.get('width') == res[0] and thumb.get('height') == res[1]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if thumb:
|
||||||
|
return thumb.get('url')
|
||||||
|
|
||||||
|
# Default fallback (best quality)
|
||||||
|
return info.get('thumbnail')
|
||||||
|
|
||||||
|
def parse(
|
||||||
|
self,
|
||||||
|
resource: str,
|
||||||
|
*_,
|
||||||
|
youtube_format: Optional[str] = None,
|
||||||
|
youtube_audio_format: Optional[str] = None,
|
||||||
|
only_audio: bool = False,
|
||||||
|
**__
|
||||||
|
) -> Optional[YoutubeMediaResource]:
|
||||||
|
if not self.is_youtube_resource(resource):
|
||||||
|
return None
|
||||||
|
|
||||||
|
youtube_format = youtube_format or self._media.youtube_format
|
||||||
|
if only_audio:
|
||||||
|
youtube_format = (
|
||||||
|
youtube_audio_format
|
||||||
|
or self._media.youtube_audio_format
|
||||||
|
or youtube_format
|
||||||
|
)
|
||||||
|
|
||||||
|
ytdl_cmd = [
|
||||||
|
self._media._ytdl, # pylint: disable=protected-access
|
||||||
|
*(['-f', youtube_format] if youtube_format else []),
|
||||||
|
*(['-x'] if only_audio else []),
|
||||||
|
'-j',
|
||||||
|
'-g',
|
||||||
|
resource,
|
||||||
|
]
|
||||||
|
|
||||||
|
self.logger.info('Executing command %s', ' '.join(ytdl_cmd))
|
||||||
|
with subprocess.Popen(ytdl_cmd, stdout=subprocess.PIPE) as ytdl:
|
||||||
|
output = ytdl.communicate()[0].decode().strip()
|
||||||
|
ytdl.wait()
|
||||||
|
|
||||||
|
self.logger.debug('yt-dlp output: %s', output)
|
||||||
|
lines = output.split('\n')
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
self.logger.warning('No output from yt-dlp')
|
||||||
|
return None
|
||||||
|
|
||||||
|
info = json.loads(lines[-1])
|
||||||
|
args = {
|
||||||
|
**{
|
||||||
|
field.name: info.get(field.name)
|
||||||
|
for field in fields(YoutubeMediaResource)
|
||||||
|
},
|
||||||
|
'url': info.get('webpage_url'),
|
||||||
|
'image': self._get_youtube_best_thumbnail(info),
|
||||||
|
'type': info.get('extractor'),
|
||||||
|
'media_plugin': self._media,
|
||||||
|
}
|
||||||
|
|
||||||
|
return YoutubeMediaResource(**args)
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
145
platypush/plugins/media/_resource/youtube.py
Normal file
145
platypush/plugins/media/_resource/youtube.py
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import IO, Optional, Sequence
|
||||||
|
|
||||||
|
from ._base import PopenMediaResource
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class YoutubeMediaResource(PopenMediaResource):
|
||||||
|
"""
|
||||||
|
Models a YouTube media resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Optional[str] = None
|
||||||
|
is_temporary: bool = False
|
||||||
|
|
||||||
|
def _generate_file(self, merge_format: str):
|
||||||
|
self.resource = os.path.join(
|
||||||
|
self._media.cache_dir, f'platypush-yt-dlp-{self.id}.{merge_format}'
|
||||||
|
)
|
||||||
|
self.is_temporary = True
|
||||||
|
|
||||||
|
def _prepare_file(self, merge_output_format: str):
|
||||||
|
if not self.resource:
|
||||||
|
self._generate_file(merge_output_format)
|
||||||
|
|
||||||
|
filename = (
|
||||||
|
self.resource[len('file://') :]
|
||||||
|
if self.resource.startswith('file://')
|
||||||
|
else self.resource
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove the file if it already exists and it's empty, to avoid YTDL
|
||||||
|
# errors
|
||||||
|
if (
|
||||||
|
os.path.exists(os.path.abspath(filename))
|
||||||
|
and os.path.getsize(os.path.abspath(filename)) == 0
|
||||||
|
):
|
||||||
|
self._logger.debug('Removing empty file: %s', filename)
|
||||||
|
os.unlink(os.path.abspath(filename))
|
||||||
|
|
||||||
|
def open(
|
||||||
|
self,
|
||||||
|
*args,
|
||||||
|
youtube_format: Optional[str] = None,
|
||||||
|
merge_output_format: Optional[str] = None,
|
||||||
|
cache_streams: bool = False,
|
||||||
|
ytdl_args: Optional[Sequence[str]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> IO:
|
||||||
|
if self.proc is None:
|
||||||
|
merge_output_format = merge_output_format or self._media.merge_output_format
|
||||||
|
use_file = (
|
||||||
|
not self._media.supports_local_pipe or cache_streams or self.resource
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_file:
|
||||||
|
self._prepare_file(merge_output_format=merge_output_format)
|
||||||
|
|
||||||
|
output = ['-o', self.resource] if use_file else ['-o', '-']
|
||||||
|
youtube_format = youtube_format or self._media.youtube_format
|
||||||
|
ytdl_args = ytdl_args or self._media.ytdl_args
|
||||||
|
cmd = [
|
||||||
|
self._media._ytdl, # pylint: disable=protected-access
|
||||||
|
'--no-part',
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
'-f',
|
||||||
|
youtube_format,
|
||||||
|
]
|
||||||
|
if youtube_format
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
*(
|
||||||
|
['--merge-output-format', merge_output_format]
|
||||||
|
if merge_output_format
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
*output,
|
||||||
|
*ytdl_args,
|
||||||
|
self.url,
|
||||||
|
]
|
||||||
|
|
||||||
|
proc_args = {}
|
||||||
|
if not use_file:
|
||||||
|
proc_args['stdout'] = subprocess.PIPE
|
||||||
|
|
||||||
|
self._logger.debug('Running command: %s', ' '.join(cmd))
|
||||||
|
self._logger.debug('Media resource: %s', self.to_dict())
|
||||||
|
self.proc = subprocess.Popen( # pylint: disable=consider-using-with
|
||||||
|
cmd, **proc_args
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_file:
|
||||||
|
self._wait_for_download_start()
|
||||||
|
self.fd = open( # pylint: disable=consider-using-with
|
||||||
|
self.resource, 'rb'
|
||||||
|
)
|
||||||
|
elif self.proc.stdout:
|
||||||
|
self.fd = self.proc.stdout
|
||||||
|
|
||||||
|
return super().open(*args, **kwargs)
|
||||||
|
|
||||||
|
def _wait_for_download_start(self) -> None:
|
||||||
|
self._logger.info('Waiting for download to start on file: %s', self.resource)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
file = self.resource
|
||||||
|
if not file:
|
||||||
|
self._logger.info('No file found to wait for download')
|
||||||
|
break
|
||||||
|
|
||||||
|
if not self.proc:
|
||||||
|
self._logger.info('No download process found to wait')
|
||||||
|
break
|
||||||
|
|
||||||
|
if self.proc.poll() is not None:
|
||||||
|
self._logger.info(
|
||||||
|
'Download process exited with status %d', self.proc.returncode
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# The file must exist and be at least 1MB in size
|
||||||
|
if os.path.exists(file) and os.path.getsize(file) > 1024 * 1024:
|
||||||
|
self._logger.info('Download started, process PID: %s', self.proc.pid)
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.proc.wait(1)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
super().close()
|
||||||
|
|
||||||
|
if self.is_temporary and self.resource and os.path.exists(self.resource):
|
||||||
|
try:
|
||||||
|
self._logger.debug('Removing temporary file: %s', self.resource)
|
||||||
|
os.unlink(self.resource)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
59
platypush/plugins/media/_search/__init__.py
Normal file
59
platypush/plugins/media/_search/__init__.py
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from platypush.plugins import Plugin
|
||||||
|
|
||||||
|
|
||||||
|
class MediaSearcher(ABC):
|
||||||
|
"""
|
||||||
|
Base class for media searchers
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *_, media_plugin: Optional[Plugin] = None, **__):
|
||||||
|
from .. import MediaPlugin
|
||||||
|
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
assert isinstance(
|
||||||
|
media_plugin, MediaPlugin
|
||||||
|
), f'Invalid media plugin: {media_plugin}'
|
||||||
|
self.media_plugin: Optional[MediaPlugin] = media_plugin
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def search(self, query, *args, **kwargs):
|
||||||
|
raise NotImplementedError(
|
||||||
|
'The search method should be implemented by a derived class'
|
||||||
|
)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def supports(self, type: str) -> bool:
|
||||||
|
raise NotImplementedError(
|
||||||
|
'The type method should be implemented by a derived class'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from .local import LocalMediaSearcher # noqa: E402
|
||||||
|
from .youtube import YoutubeMediaSearcher # noqa: E402
|
||||||
|
from .torrent import TorrentMediaSearcher # noqa: E402
|
||||||
|
from .plex import PlexMediaSearcher # noqa: E402
|
||||||
|
from .jellyfin import JellyfinMediaSearcher # noqa: E402
|
||||||
|
|
||||||
|
searchers = [
|
||||||
|
LocalMediaSearcher,
|
||||||
|
YoutubeMediaSearcher,
|
||||||
|
TorrentMediaSearcher,
|
||||||
|
PlexMediaSearcher,
|
||||||
|
JellyfinMediaSearcher,
|
||||||
|
]
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'JellyfinMediaSearcher',
|
||||||
|
'LocalMediaSearcher',
|
||||||
|
'MediaSearcher',
|
||||||
|
'PlexMediaSearcher',
|
||||||
|
'TorrentMediaSearcher',
|
||||||
|
'YoutubeMediaSearcher',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
|
@ -1,9 +1,16 @@
|
||||||
from platypush.context import get_plugin
|
from platypush.context import get_plugin
|
||||||
from platypush.plugins.media.search import MediaSearcher
|
from platypush.plugins.media._search import MediaSearcher
|
||||||
|
|
||||||
|
|
||||||
class JellyfinMediaSearcher(MediaSearcher):
|
class JellyfinMediaSearcher(MediaSearcher):
|
||||||
def search(self, query, **_):
|
"""
|
||||||
|
Jellyfin media searcher.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def supports(self, type: str) -> bool:
|
||||||
|
return type == 'jellyfin'
|
||||||
|
|
||||||
|
def search(self, query, *_, **__):
|
||||||
"""
|
"""
|
||||||
Performs a search on a Jellyfin server using the configured
|
Performs a search on a Jellyfin server using the configured
|
||||||
:class:`platypush.plugins.media.jellyfin.MediaJellyfinPlugin`
|
:class:`platypush.plugins.media.jellyfin.MediaJellyfinPlugin`
|
||||||
|
@ -18,9 +25,11 @@ class JellyfinMediaSearcher(MediaSearcher):
|
||||||
if not media:
|
if not media:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
self.logger.info('Searching Jellyfin for "{}"'.format(query))
|
self.logger.info('Searching Jellyfin for "%s"', query)
|
||||||
results = media.search(query=query).output
|
results = media.search(query=query).output
|
||||||
self.logger.info('{} Jellyfin results found for the search query "{}"'.format(len(results), query))
|
self.logger.info(
|
||||||
|
'%d Jellyfin results found for the search query "%s"', len(results), query
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,7 @@ from sqlalchemy import create_engine
|
||||||
from sqlalchemy.sql.expression import func
|
from sqlalchemy.sql.expression import func
|
||||||
|
|
||||||
from platypush.config import Config
|
from platypush.config import Config
|
||||||
from platypush.plugins.media import MediaPlugin
|
from platypush.plugins.media._search import MediaSearcher
|
||||||
from platypush.plugins.media.search import MediaSearcher
|
|
||||||
|
|
||||||
from .db import (
|
from .db import (
|
||||||
Base,
|
Base,
|
||||||
|
@ -43,6 +42,9 @@ class LocalMediaSearcher(MediaSearcher):
|
||||||
self.db_file = os.path.join(db_dir, 'media.db')
|
self.db_file = os.path.join(db_dir, 'media.db')
|
||||||
self._db_engine = None
|
self._db_engine = None
|
||||||
|
|
||||||
|
def supports(self, type: str) -> bool:
|
||||||
|
return type == 'file'
|
||||||
|
|
||||||
def _get_db_session(self):
|
def _get_db_session(self):
|
||||||
if not self._db_engine:
|
if not self._db_engine:
|
||||||
self._db_engine = create_engine(
|
self._db_engine = create_engine(
|
||||||
|
@ -124,6 +126,7 @@ class LocalMediaSearcher(MediaSearcher):
|
||||||
Scans a media directory and stores the search results in the internal
|
Scans a media directory and stores the search results in the internal
|
||||||
SQLite index
|
SQLite index
|
||||||
"""
|
"""
|
||||||
|
from platypush.plugins.media import MediaPlugin
|
||||||
|
|
||||||
if not session:
|
if not session:
|
||||||
session = self._get_db_session()
|
session = self._get_db_session()
|
|
@ -3,11 +3,11 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
|
||||||
from dateutil.parser import isoparse
|
from dateutil.parser import isoparse
|
||||||
import shutil
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -58,6 +58,8 @@ def get_file_metadata(path: str):
|
||||||
'duration': ret.get('format', {}).get('duration'),
|
'duration': ret.get('format', {}).get('duration'),
|
||||||
'width': video_stream.get('width'),
|
'width': video_stream.get('width'),
|
||||||
'height': video_stream.get('height'),
|
'height': video_stream.get('height'),
|
||||||
|
'resolution': f"{video_stream.get('width')}x{video_stream.get('height')}",
|
||||||
|
'size': os.path.getsize(path),
|
||||||
'created_at': creation_time,
|
'created_at': creation_time,
|
||||||
}
|
}
|
||||||
|
|
91
platypush/plugins/media/_search/plex.py
Normal file
91
platypush/plugins/media/_search/plex.py
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
from platypush.context import get_plugin
|
||||||
|
from platypush.plugins.media._search import MediaSearcher
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
class PlexMediaSearcher(MediaSearcher):
|
||||||
|
"""
|
||||||
|
Plex media searcher.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def supports(self, type: str) -> bool:
|
||||||
|
return type == 'plex'
|
||||||
|
|
||||||
|
def search(self, query: str, *_, **__):
|
||||||
|
"""
|
||||||
|
Performs a Plex search using the configured :class:`platypush.plugins.media.plex.MediaPlexPlugin` instance if
|
||||||
|
it is available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
plex = get_plugin('media.plex')
|
||||||
|
except RuntimeError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
assert plex, 'No Plex plugin configured'
|
||||||
|
self.logger.info('Searching Plex for "%s"', query)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for result in plex.search(title=query).output:
|
||||||
|
results.extend(self._flatten_result(result))
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
'%d Plex results found for the search query "%s"', len(results), query
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _flatten_result(result):
|
||||||
|
def parse_part(media, part, episode=None, sub_media=None):
|
||||||
|
if 'episodes' in media:
|
||||||
|
del media['episodes']
|
||||||
|
|
||||||
|
return {
|
||||||
|
**{k: v for k, v in result.items() if k not in ['media', 'type']},
|
||||||
|
'media_type': result.get('type'),
|
||||||
|
'type': 'plex',
|
||||||
|
**{k: v for k, v in media.items() if k not in ['parts']},
|
||||||
|
**part,
|
||||||
|
'title': (
|
||||||
|
result.get('title', '')
|
||||||
|
+ (
|
||||||
|
' [' + (episode or {}).get('season_episode', '') + ']'
|
||||||
|
if (episode or {}).get('season_episode')
|
||||||
|
else ''
|
||||||
|
)
|
||||||
|
+ (
|
||||||
|
' ' + (sub_media or {}).get('title', '')
|
||||||
|
if (sub_media or {}).get('title')
|
||||||
|
else ''
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'summary': (
|
||||||
|
(episode or {}).get('summary')
|
||||||
|
if (episode or {}).get('summary')
|
||||||
|
else media.get('summary')
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for media in result.get('media', []):
|
||||||
|
if 'episodes' in media:
|
||||||
|
for episode in media['episodes']:
|
||||||
|
for sub_media in episode.get('media', []):
|
||||||
|
for part in sub_media.get('parts', []):
|
||||||
|
results.append(
|
||||||
|
parse_part(
|
||||||
|
media=media,
|
||||||
|
episode=episode,
|
||||||
|
sub_media=sub_media,
|
||||||
|
part=part,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for part in media.get('parts', []):
|
||||||
|
results.append(parse_part(media=media, part=part))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# vim:sw=4:ts=4:et:
|
|
@ -1,14 +1,22 @@
|
||||||
from platypush.context import get_plugin
|
from platypush.context import get_plugin
|
||||||
from platypush.plugins.media.search import MediaSearcher
|
from platypush.plugins.media._search import MediaSearcher
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
class TorrentMediaSearcher(MediaSearcher):
|
class TorrentMediaSearcher(MediaSearcher):
|
||||||
def search(self, query, **kwargs):
|
"""
|
||||||
self.logger.info('Searching torrents for "{}"'.format(query))
|
Media searcher for torrents.
|
||||||
|
|
||||||
|
It needs at least one torrent plugin to be configured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def search(self, query: str, *_, **__):
|
||||||
|
self.logger.info('Searching torrents for "%s"', query)
|
||||||
|
|
||||||
torrents = get_plugin(
|
torrents = get_plugin(
|
||||||
self.media_plugin.torrent_plugin if self.media_plugin else 'torrent'
|
self.media_plugin.torrent_plugin if self.media_plugin else 'torrent'
|
||||||
)
|
)
|
||||||
|
|
||||||
if not torrents:
|
if not torrents:
|
||||||
raise RuntimeError('Torrent plugin not available/configured')
|
raise RuntimeError('Torrent plugin not available/configured')
|
||||||
|
|
||||||
|
@ -20,5 +28,8 @@ class TorrentMediaSearcher(MediaSearcher):
|
||||||
if torrent.get('is_media')
|
if torrent.get('is_media')
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def supports(self, type: str) -> bool:
|
||||||
|
return type == 'torrent'
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
|
@ -1,5 +1,5 @@
|
||||||
from platypush.context import get_plugin
|
from platypush.context import get_plugin
|
||||||
from platypush.plugins.media.search import MediaSearcher
|
from platypush.plugins.media._search import MediaSearcher
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-few-public-methods
|
# pylint: disable=too-few-public-methods
|
||||||
|
@ -18,5 +18,8 @@ class YoutubeMediaSearcher(MediaSearcher):
|
||||||
assert yt, 'YouTube plugin not available/configured'
|
assert yt, 'YouTube plugin not available/configured'
|
||||||
return yt.search(query=query).output
|
return yt.search(query=query).output
|
||||||
|
|
||||||
|
def supports(self, type: str) -> bool:
|
||||||
|
return type == 'youtube'
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
|
@ -1,12 +0,0 @@
|
||||||
import enum
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerState(enum.Enum):
|
|
||||||
"""
|
|
||||||
Models the possible states of a media player
|
|
||||||
"""
|
|
||||||
|
|
||||||
STOP = 'stop'
|
|
||||||
PLAY = 'play'
|
|
||||||
PAUSE = 'pause'
|
|
||||||
IDLE = 'idle'
|
|
|
@ -1,4 +1,5 @@
|
||||||
from typing import Optional
|
from typing import Dict, Optional, Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from pychromecast import (
|
from pychromecast import (
|
||||||
CastBrowser,
|
CastBrowser,
|
||||||
|
@ -10,9 +11,14 @@ from pychromecast import (
|
||||||
|
|
||||||
from platypush.backend.http.app.utils import get_remote_base_url
|
from platypush.backend.http.app.utils import get_remote_base_url
|
||||||
from platypush.plugins import RunnablePlugin, action
|
from platypush.plugins import RunnablePlugin, action
|
||||||
from platypush.plugins.media import MediaPlugin
|
from platypush.plugins.media import MediaPlugin, MediaResource
|
||||||
|
from platypush.plugins.media._resource.youtube import YoutubeMediaResource
|
||||||
from platypush.utils import get_mime_type
|
from platypush.utils import get_mime_type
|
||||||
from platypush.message.event.media import MediaPlayRequestEvent
|
from platypush.message.event.media import (
|
||||||
|
MediaEvent,
|
||||||
|
MediaPlayRequestEvent,
|
||||||
|
MediaStopEvent,
|
||||||
|
)
|
||||||
|
|
||||||
from ._listener import MediaListener
|
from ._listener import MediaListener
|
||||||
from ._subtitles import SubtitlesAsyncHandler
|
from ._subtitles import SubtitlesAsyncHandler
|
||||||
|
@ -29,22 +35,49 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
|
||||||
STREAM_TYPE_LIVE = "LIVE"
|
STREAM_TYPE_LIVE = "LIVE"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, chromecast: Optional[str] = None, poll_interval: float = 30, **kwargs
|
self,
|
||||||
|
chromecast: Optional[str] = None,
|
||||||
|
poll_interval: float = 30,
|
||||||
|
youtube_format: Optional[str] = 'bv[width<=?1080][ext=mp4]+ba[ext=m4a]/bv+ba',
|
||||||
|
merge_output_format: str = 'mp4',
|
||||||
|
# Transcode to H.264/AAC to maximimze compatibility with Chromecast codecs
|
||||||
|
ytdl_args: Optional[Sequence[str]] = (
|
||||||
|
'--use-postprocessor',
|
||||||
|
'FFmpegCopyStream',
|
||||||
|
'--ppa',
|
||||||
|
'CopyStream:"-c:v libx264 -preset veryfast -crf 28 +faststart -c:a aac"',
|
||||||
|
),
|
||||||
|
use_ytdl: bool = True,
|
||||||
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
:param chromecast: Default Chromecast to cast to if no name is specified.
|
:param chromecast: Default Chromecast to cast to if no name is specified.
|
||||||
:param poll_interval: How often the plugin should poll for new/removed
|
:param poll_interval: How often the plugin should poll for new/removed
|
||||||
Chromecast devices (default: 30 seconds).
|
Chromecast devices (default: 30 seconds).
|
||||||
|
:param use_ytdl: Use youtube-dl to download the media if we are dealing
|
||||||
|
with formats compatible with youtube-dl/yt-dlp. Disable this option
|
||||||
|
if you experience issues with the media playback on the Chromecast,
|
||||||
|
such as media with no video, no audio or both. This option will
|
||||||
|
disable muxing+transcoding over the HTTP server and will stream the
|
||||||
|
URL directly to the Chromecast.
|
||||||
"""
|
"""
|
||||||
super().__init__(poll_interval=poll_interval, **kwargs)
|
super().__init__(
|
||||||
|
poll_interval=poll_interval,
|
||||||
|
youtube_format=youtube_format,
|
||||||
|
merge_output_format=merge_output_format,
|
||||||
|
ytdl_args=ytdl_args,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
self._is_local = False
|
self._is_local = False
|
||||||
self.chromecast = chromecast
|
self.chromecast = chromecast
|
||||||
self._chromecasts_by_uuid = {}
|
self._chromecasts_by_uuid = {}
|
||||||
self._chromecasts_by_name = {}
|
self._chromecasts_by_name = {}
|
||||||
self._media_listeners = {}
|
self._media_listeners = {}
|
||||||
|
self._latest_resources_by_device: Dict[UUID, MediaResource] = {}
|
||||||
self._zc = None
|
self._zc = None
|
||||||
self._browser = None
|
self._browser = None
|
||||||
|
self._use_ytdl = use_ytdl
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def zc(self):
|
def zc(self):
|
||||||
|
@ -90,6 +123,9 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
|
||||||
else:
|
else:
|
||||||
raise RuntimeError('Invalid Chromecast object')
|
raise RuntimeError('Invalid Chromecast object')
|
||||||
|
|
||||||
|
resource = self._latest_resources_by_device.get(cc.uuid)
|
||||||
|
resource_dump = resource.to_dict() if resource else {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'type': cc.cast_type,
|
'type': cc.cast_type,
|
||||||
'name': cc.name,
|
'name': cc.name,
|
||||||
|
@ -111,19 +147,25 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
|
||||||
'volume': round(100 * cc.status.volume_level, 2),
|
'volume': round(100 * cc.status.volume_level, 2),
|
||||||
'muted': cc.status.volume_muted,
|
'muted': cc.status.volume_muted,
|
||||||
**convert_status(cc.media_controller.status),
|
**convert_status(cc.media_controller.status),
|
||||||
|
**resource_dump,
|
||||||
}
|
}
|
||||||
if cc.status
|
if cc.status
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _event_callback(self, _, cast: Chromecast):
|
def _event_callback(self, evt: MediaEvent, cast: Chromecast):
|
||||||
|
if isinstance(evt, MediaStopEvent):
|
||||||
|
resource = self._latest_resources_by_device.pop(cast.uuid, None)
|
||||||
|
if resource:
|
||||||
|
resource.close()
|
||||||
|
|
||||||
self._chromecasts_by_uuid[cast.uuid] = cast
|
self._chromecasts_by_uuid[cast.uuid] = cast
|
||||||
self._chromecasts_by_name[
|
self._chromecasts_by_name[
|
||||||
self._get_device_property(cast, 'friendly_name')
|
self._get_device_property(cast, 'friendly_name')
|
||||||
] = cast
|
] = cast
|
||||||
|
|
||||||
def get_chromecast(self, chromecast=None):
|
def get_chromecast(self, chromecast=None) -> Chromecast:
|
||||||
if isinstance(chromecast, Chromecast):
|
if isinstance(chromecast, Chromecast):
|
||||||
return chromecast
|
return chromecast
|
||||||
|
|
||||||
|
@ -151,7 +193,9 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
|
||||||
subtitles_lang: str = 'en-US',
|
subtitles_lang: str = 'en-US',
|
||||||
subtitles_mime: str = 'text/vtt',
|
subtitles_mime: str = 'text/vtt',
|
||||||
subtitle_id: int = 1,
|
subtitle_id: int = 1,
|
||||||
**__,
|
youtube_format: Optional[str] = None,
|
||||||
|
use_ytdl: Optional[bool] = None,
|
||||||
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Cast media to an available Chromecast device.
|
Cast media to an available Chromecast device.
|
||||||
|
@ -171,6 +215,8 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
|
||||||
:param subtitles_lang: Subtitles language (default: en-US)
|
:param subtitles_lang: Subtitles language (default: en-US)
|
||||||
:param subtitles_mime: Subtitles MIME type (default: text/vtt)
|
:param subtitles_mime: Subtitles MIME type (default: text/vtt)
|
||||||
:param subtitle_id: ID of the subtitles to be loaded (default: 1)
|
:param subtitle_id: ID of the subtitles to be loaded (default: 1)
|
||||||
|
:param youtube_format: Override the default YouTube format.
|
||||||
|
:param use_ytdl: Override the default use_ytdl setting for this call.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not chromecast:
|
if not chromecast:
|
||||||
|
@ -179,10 +225,26 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
|
||||||
post_event(MediaPlayRequestEvent, resource=resource, device=chromecast)
|
post_event(MediaPlayRequestEvent, resource=resource, device=chromecast)
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
mc = cast.media_controller
|
mc = cast.media_controller
|
||||||
resource = self._get_resource(resource)
|
media = self._latest_resource = self._latest_resources_by_device[
|
||||||
|
cast.uuid
|
||||||
|
] = self._get_resource(resource, **kwargs)
|
||||||
|
|
||||||
|
youtube_format = youtube_format or self.youtube_format
|
||||||
|
use_ytdl = use_ytdl if use_ytdl is not None else self._use_ytdl
|
||||||
|
ytdl_args = kwargs.pop('ytdl_args', self.ytdl_args)
|
||||||
|
|
||||||
|
if isinstance(media, YoutubeMediaResource) and not use_ytdl:
|
||||||
|
# Use the original URL if it's a YouTube video and we're not using youtube-dl
|
||||||
|
media.resource = media.url
|
||||||
|
else:
|
||||||
|
media.open(youtube_format=youtube_format, ytdl_args=ytdl_args, **kwargs)
|
||||||
|
|
||||||
|
assert media.resource, 'No playable resource found'
|
||||||
|
resource = media.resource
|
||||||
|
self.logger.debug('Opened media resource: %s', media.to_dict())
|
||||||
|
|
||||||
if not content_type:
|
if not content_type:
|
||||||
content_type = get_mime_type(resource)
|
content_type = get_mime_type(media.resource)
|
||||||
|
|
||||||
if not content_type:
|
if not content_type:
|
||||||
raise RuntimeError(f'content_type required to process media {resource}')
|
raise RuntimeError(f'content_type required to process media {resource}')
|
||||||
|
@ -192,19 +254,13 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
|
||||||
resource = get_remote_base_url() + resource
|
resource = get_remote_base_url() + resource
|
||||||
self.logger.info('HTTP media stream started on %s', resource)
|
self.logger.info('HTTP media stream started on %s', resource)
|
||||||
|
|
||||||
if self._latest_resource:
|
|
||||||
if not title:
|
|
||||||
title = self._latest_resource.title
|
|
||||||
if not image_url:
|
|
||||||
image_url = self._latest_resource.image
|
|
||||||
|
|
||||||
self.logger.info('Playing %s on %s', resource, chromecast)
|
self.logger.info('Playing %s on %s', resource, chromecast)
|
||||||
|
|
||||||
mc.play_media(
|
mc.play_media(
|
||||||
resource,
|
resource,
|
||||||
content_type,
|
content_type,
|
||||||
title=self._latest_resource.title if self._latest_resource else title,
|
title=title or media.title,
|
||||||
thumb=image_url,
|
thumb=image_url or media.image,
|
||||||
current_time=current_time,
|
current_time=current_time,
|
||||||
autoplay=autoplay,
|
autoplay=autoplay,
|
||||||
stream_type=stream_type,
|
stream_type=stream_type,
|
||||||
|
@ -629,6 +685,15 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
|
||||||
self._chromecasts_by_uuid[cc.uuid] = cc
|
self._chromecasts_by_uuid[cc.uuid] = cc
|
||||||
self._chromecasts_by_name[name] = cc
|
self._chromecasts_by_name[name] = cc
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_local_media(self) -> bool:
|
||||||
|
# Chromecasts can't play local media: they always need an HTTP URL
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_local_pipe(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
def main(self):
|
def main(self):
|
||||||
while not self.should_stop():
|
while not self.should_stop():
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional, Type
|
||||||
|
|
||||||
from platypush.message.event.media import (
|
from platypush.message.event.media import (
|
||||||
|
MediaEvent,
|
||||||
MediaPlayEvent,
|
MediaPlayEvent,
|
||||||
MediaStopEvent,
|
MediaStopEvent,
|
||||||
MediaPauseEvent,
|
MediaPauseEvent,
|
||||||
|
@ -42,7 +43,10 @@ class MediaListener:
|
||||||
self._post_event(MediaPlayEvent)
|
self._post_event(MediaPlayEvent)
|
||||||
elif state == 'pause':
|
elif state == 'pause':
|
||||||
self._post_event(MediaPauseEvent)
|
self._post_event(MediaPauseEvent)
|
||||||
elif state in ('stop', 'idle'):
|
elif state in ('stop', 'idle') and self.status.get('state') in (
|
||||||
|
'play',
|
||||||
|
'pause',
|
||||||
|
):
|
||||||
self._post_event(MediaStopEvent)
|
self._post_event(MediaStopEvent)
|
||||||
|
|
||||||
if status.get('volume') != self.status.get('volume'):
|
if status.get('volume') != self.status.get('volume'):
|
||||||
|
@ -62,7 +66,7 @@ class MediaListener:
|
||||||
def load_media_failed(self, item, error_code):
|
def load_media_failed(self, item, error_code):
|
||||||
logger.warning('Failed to load media %s: %d', item, error_code)
|
logger.warning('Failed to load media %s: %d', item, error_code)
|
||||||
|
|
||||||
def _post_event(self, evt_type, **evt):
|
def _post_event(self, evt_type: Type[MediaEvent], **evt):
|
||||||
status = evt.get('status', {})
|
status = evt.get('status', {})
|
||||||
resource = status.get('url')
|
resource = status.get('url')
|
||||||
args = {
|
args = {
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
from dataclasses import asdict
|
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
@ -6,6 +5,7 @@ from platypush.plugins.media import PlayerState, MediaPlugin
|
||||||
from platypush.message.event.media import MediaPlayRequestEvent, MediaVolumeChangedEvent
|
from platypush.message.event.media import MediaPlayRequestEvent, MediaVolumeChangedEvent
|
||||||
|
|
||||||
from platypush.plugins import action
|
from platypush.plugins import action
|
||||||
|
from platypush.plugins.media import MediaResource
|
||||||
from platypush.plugins.media.gstreamer.model import MediaPipeline
|
from platypush.plugins.media.gstreamer.model import MediaPipeline
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,20 +21,61 @@ class MediaGstreamerPlugin(MediaPlugin):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self._player: Optional[MediaPipeline] = None
|
self._player: Optional[MediaPipeline] = None
|
||||||
self._resource: Optional[str] = None
|
|
||||||
|
|
||||||
def _allocate_pipeline(self, resource: str) -> MediaPipeline:
|
def _allocate_pipeline(self, resource: MediaResource) -> MediaPipeline:
|
||||||
pipeline = MediaPipeline(resource)
|
pipeline = MediaPipeline(resource)
|
||||||
if self.sink:
|
if self.sink:
|
||||||
sink = pipeline.add_sink(self.sink, sync=False)
|
sink = pipeline.add_sink(self.sink, sync=False)
|
||||||
pipeline.link(pipeline.get_source(), sink)
|
pipeline.link(pipeline.get_source(), sink)
|
||||||
|
|
||||||
self._player = pipeline
|
self._player = pipeline
|
||||||
self._resource = resource
|
self._latest_resource = resource
|
||||||
return pipeline
|
return pipeline
|
||||||
|
|
||||||
|
def _status(self) -> dict:
|
||||||
|
if not self._player:
|
||||||
|
return {'state': PlayerState.STOP.value}
|
||||||
|
|
||||||
|
pos = self._player.get_position()
|
||||||
|
length = self._player.get_duration()
|
||||||
|
|
||||||
|
status = {
|
||||||
|
'duration': length,
|
||||||
|
'mute': self._player.is_muted(),
|
||||||
|
'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
|
||||||
|
),
|
||||||
|
'position': pos,
|
||||||
|
'seekable': length is not None and length > 0,
|
||||||
|
'state': self._gst_to_player_state(self._player.get_state()).value,
|
||||||
|
'volume': self._player.get_volume() * 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._latest_resource:
|
||||||
|
status.update(
|
||||||
|
{
|
||||||
|
k: v
|
||||||
|
for k, v in self._latest_resource.to_dict().items()
|
||||||
|
if v is not None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
def _get_volume(self) -> float:
|
||||||
|
assert self._player, 'No instance is running'
|
||||||
|
return self._player.get_volume() * 100.0
|
||||||
|
|
||||||
|
def _set_position(self, position: float) -> dict:
|
||||||
|
assert self._player, 'No instance is running'
|
||||||
|
self._player.seek(position)
|
||||||
|
return self._status()
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def play(self, resource: Optional[str] = None, **_):
|
def play(self, resource: Optional[str] = None, **kwargs):
|
||||||
"""
|
"""
|
||||||
Play a resource.
|
Play a resource.
|
||||||
|
|
||||||
|
@ -44,36 +85,45 @@ class MediaGstreamerPlugin(MediaPlugin):
|
||||||
if not resource:
|
if not resource:
|
||||||
if self._player:
|
if self._player:
|
||||||
self._player.play()
|
self._player.play()
|
||||||
return
|
return self._status()
|
||||||
|
|
||||||
resource = self._get_resource(resource)
|
self._bus.post(
|
||||||
path = os.path.abspath(os.path.expanduser(resource))
|
MediaPlayRequestEvent(
|
||||||
if os.path.exists(path):
|
player='local', plugin='media.gstreamer', resource=resource
|
||||||
resource = 'file://' + path
|
)
|
||||||
|
)
|
||||||
|
media = self._latest_resource = self._get_resource(resource, **kwargs)
|
||||||
|
media.open(**kwargs)
|
||||||
|
if media.resource and os.path.isfile(os.path.abspath(media.resource)):
|
||||||
|
media.resource = 'file://' + media.resource
|
||||||
|
|
||||||
MediaPipeline.post_event(MediaPlayRequestEvent, resource=resource)
|
pipeline = self._allocate_pipeline(media)
|
||||||
pipeline = self._allocate_pipeline(resource)
|
|
||||||
pipeline.play()
|
pipeline.play()
|
||||||
if self.volume:
|
if self.volume:
|
||||||
pipeline.set_volume(self.volume / 100.0)
|
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()
|
if self._latest_resource:
|
||||||
assert self._player, 'No instance is running'
|
self._latest_resource.close()
|
||||||
|
self._latest_resource = None
|
||||||
|
|
||||||
|
if self._player:
|
||||||
|
self._player.stop()
|
||||||
|
self._player = None
|
||||||
|
else:
|
||||||
|
self.logger.info('No instance is running')
|
||||||
|
|
||||||
self._player.stop()
|
|
||||||
self._player = None
|
|
||||||
return {'state': PlayerState.STOP.value}
|
return {'state': PlayerState.STOP.value}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -84,14 +134,12 @@ class MediaGstreamerPlugin(MediaPlugin):
|
||||||
@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
|
return self.set_volume(self._get_volume() - 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
|
return self.set_volume(self._get_volume() + step)
|
||||||
return self.set_volume(self.get_volume().output + step)
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def get_volume(self) -> float:
|
def get_volume(self) -> float:
|
||||||
|
@ -111,11 +159,10 @@ class MediaGstreamerPlugin(MediaPlugin):
|
||||||
:param volume: Volume value between 0 and 100.
|
:param volume: Volume value between 0 and 100.
|
||||||
"""
|
"""
|
||||||
assert self._player, 'Player not running'
|
assert self._player, 'Player not running'
|
||||||
# noinspection PyTypeChecker
|
|
||||||
volume = max(0, min(1, volume / 100.0))
|
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)
|
self._player.post_event(MediaVolumeChangedEvent, volume=volume * 100)
|
||||||
return self.status()
|
return self._status()
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def seek(self, position: float) -> dict:
|
def seek(self, position: float) -> dict:
|
||||||
|
@ -126,7 +173,8 @@ class MediaGstreamerPlugin(MediaPlugin):
|
||||||
"""
|
"""
|
||||||
assert self._player, 'No instance is running'
|
assert self._player, 'No instance is running'
|
||||||
cur_pos = self._player.get_position()
|
cur_pos = self._player.get_position()
|
||||||
return self.set_position(cur_pos + position)
|
# return self._set_position(cur_pos + position)
|
||||||
|
return self._set_position((cur_pos or 0) + float(position))
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def back(self, offset=60.0):
|
def back(self, offset=60.0):
|
||||||
|
@ -174,60 +222,25 @@ class MediaGstreamerPlugin(MediaPlugin):
|
||||||
"""
|
"""
|
||||||
assert self._player, 'No instance is running'
|
assert self._player, 'No instance is running'
|
||||||
self._player.seek(position)
|
self._player.seek(position)
|
||||||
return self.status()
|
return self._status()
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def status(self) -> dict:
|
def status(self) -> dict:
|
||||||
"""
|
"""
|
||||||
Get the current player state.
|
Get the current player state.
|
||||||
"""
|
"""
|
||||||
if not self._player:
|
return self._status()
|
||||||
return {'state': PlayerState.STOP.value}
|
|
||||||
|
|
||||||
pos = self._player.get_position()
|
|
||||||
length = self._player.get_duration()
|
|
||||||
|
|
||||||
status = {
|
|
||||||
'duration': length,
|
|
||||||
'filename': self._resource[7:]
|
|
||||||
if self._resource.startswith('file://')
|
|
||||||
else self._resource,
|
|
||||||
'mute': self._player.is_muted(),
|
|
||||||
'name': self._resource,
|
|
||||||
'pause': self._player.is_paused(),
|
|
||||||
'percent_pos': pos / length
|
|
||||||
if pos is not None and length is not None and pos >= 0 and length > 0
|
|
||||||
else 0,
|
|
||||||
'position': pos,
|
|
||||||
'seekable': length is not None and length > 0,
|
|
||||||
'state': self._gst_to_player_state(self._player.get_state()).value,
|
|
||||||
'url': self._resource,
|
|
||||||
'volume': self._player.get_volume() * 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
if self._latest_resource:
|
|
||||||
status.update(
|
|
||||||
{
|
|
||||||
k: v
|
|
||||||
for k, v in asdict(self._latest_resource).items()
|
|
||||||
if v is not None
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return status
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _gst_to_player_state(state) -> PlayerState:
|
def _gst_to_player_state(state) -> PlayerState:
|
||||||
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
from gi.repository import Gst # type: ignore
|
||||||
from gi.repository import Gst
|
|
||||||
|
|
||||||
if state == Gst.State.READY:
|
|
||||||
return PlayerState.STOP
|
|
||||||
if state == Gst.State.PAUSED:
|
if state == Gst.State.PAUSED:
|
||||||
return PlayerState.PAUSE
|
return PlayerState.PAUSE
|
||||||
if state == Gst.State.PLAYING:
|
if state == Gst.State.PLAYING:
|
||||||
return PlayerState.PLAY
|
return PlayerState.PLAY
|
||||||
return PlayerState.IDLE
|
|
||||||
|
return PlayerState.STOP
|
||||||
|
|
||||||
def toggle_subtitles(self, *_, **__):
|
def toggle_subtitles(self, *_, **__):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
|
@ -2,41 +2,64 @@ from typing import Type
|
||||||
|
|
||||||
from platypush.common.gstreamer import Pipeline
|
from platypush.common.gstreamer import Pipeline
|
||||||
from platypush.context import get_bus
|
from platypush.context import get_bus
|
||||||
from platypush.message.event.media import MediaEvent, MediaPlayEvent, MediaPauseEvent, MediaStopEvent, \
|
from platypush.message.event.media import (
|
||||||
NewPlayingMediaEvent, MediaMuteChangedEvent, MediaSeekEvent
|
MediaEvent,
|
||||||
|
MediaPlayEvent,
|
||||||
|
MediaPauseEvent,
|
||||||
|
MediaStopEvent,
|
||||||
|
NewPlayingMediaEvent,
|
||||||
|
MediaMuteChangedEvent,
|
||||||
|
MediaSeekEvent,
|
||||||
|
)
|
||||||
|
from platypush.plugins.media import MediaResource
|
||||||
|
|
||||||
|
|
||||||
class MediaPipeline(Pipeline):
|
class MediaPipeline(Pipeline):
|
||||||
def __init__(self, resource: str):
|
def __init__(self, resource: MediaResource):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.resource = resource
|
|
||||||
self.add_source('playbin', uri=resource)
|
|
||||||
|
|
||||||
@staticmethod
|
self.resource = resource
|
||||||
def post_event(evt_class: Type[MediaEvent], **kwargs):
|
self._first_play = True
|
||||||
|
if resource.resource and resource.fd is None:
|
||||||
|
self.add_source('playbin', uri=resource.resource)
|
||||||
|
elif resource.fd is not None:
|
||||||
|
self.add_source('playbin', uri=f'fd://{resource.fd.fileno()}')
|
||||||
|
else:
|
||||||
|
raise AssertionError('No resource specified')
|
||||||
|
|
||||||
|
def post_event(self, evt_class: Type[MediaEvent], **kwargs):
|
||||||
kwargs['player'] = 'local'
|
kwargs['player'] = 'local'
|
||||||
kwargs['plugin'] = 'media.gstreamer'
|
kwargs['plugin'] = 'media.gstreamer'
|
||||||
|
|
||||||
|
if self.resource:
|
||||||
|
resource_args = self.resource.to_dict()
|
||||||
|
resource_args.pop('type', None)
|
||||||
|
kwargs.update(resource_args)
|
||||||
|
|
||||||
evt = evt_class(**kwargs)
|
evt = evt_class(**kwargs)
|
||||||
get_bus().post(evt)
|
get_bus().post(evt)
|
||||||
|
|
||||||
def play(self):
|
def on_play(self):
|
||||||
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
super().on_play()
|
||||||
from gi.repository import Gst
|
if self._first_play:
|
||||||
is_first_play = self.get_state() == Gst.State.NULL
|
|
||||||
|
|
||||||
super().play()
|
|
||||||
if is_first_play:
|
|
||||||
self.post_event(NewPlayingMediaEvent, resource=self.resource)
|
self.post_event(NewPlayingMediaEvent, resource=self.resource)
|
||||||
self.post_event(MediaPlayEvent, resource=self.resource)
|
self._first_play = False
|
||||||
|
|
||||||
def pause(self):
|
self.post_event(MediaPlayEvent)
|
||||||
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
||||||
|
def on_pause(self):
|
||||||
|
super().on_pause()
|
||||||
|
self.post_event(MediaPauseEvent)
|
||||||
|
|
||||||
|
def play(self):
|
||||||
from gi.repository import Gst
|
from gi.repository import Gst
|
||||||
super().pause()
|
|
||||||
self.post_event(MediaPauseEvent if self.get_state() == Gst.State.PAUSED else MediaPlayEvent)
|
self._first_play = self.get_state() == Gst.State.NULL
|
||||||
|
super().play()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
super().stop()
|
super().stop()
|
||||||
|
self._first_play = True
|
||||||
self.post_event(MediaStopEvent)
|
self.post_event(MediaStopEvent)
|
||||||
|
|
||||||
def mute(self):
|
def mute(self):
|
||||||
|
@ -48,7 +71,20 @@ class MediaPipeline(Pipeline):
|
||||||
self.post_event(MediaMuteChangedEvent, mute=self.is_muted())
|
self.post_event(MediaMuteChangedEvent, mute=self.is_muted())
|
||||||
|
|
||||||
def seek(self, position: float):
|
def seek(self, position: float):
|
||||||
super().seek(position)
|
from gi.repository import Gst
|
||||||
|
|
||||||
|
if not self.source:
|
||||||
|
self.logger.info('Cannot seek on a pipeline without a source')
|
||||||
|
return
|
||||||
|
|
||||||
|
position = max(0, position)
|
||||||
|
duration = self.get_duration()
|
||||||
|
if duration and position > duration:
|
||||||
|
position = duration
|
||||||
|
|
||||||
|
cur_pos = self.get_position() or 0
|
||||||
|
seek_ns = int((position - cur_pos) * 1e9)
|
||||||
|
self.source.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH, seek_ns)
|
||||||
self.post_event(MediaSeekEvent, position=self.get_position())
|
self.post_event(MediaSeekEvent, position=self.get_position())
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from platypush.context import get_bus
|
from platypush.context import get_bus
|
||||||
from platypush.plugins import action
|
from platypush.plugins import action
|
||||||
|
@ -19,42 +21,36 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
Plugin to interact with a Kodi media player instance
|
Plugin to interact with a Kodi media player instance
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# noinspection HttpUrlsUsage
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
host,
|
rpc_url: str = 'http://localhost:8080/jsonrpc',
|
||||||
http_port=8080,
|
websocket_port: int = 9090,
|
||||||
websocket_port=9090,
|
username: Optional[str] = None,
|
||||||
username=None,
|
password: Optional[str] = None,
|
||||||
password=None,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
:param host: Kodi host name or IP
|
:param rpc_url: Base URL for the Kodi JSON RPC API (default:
|
||||||
:type host: str
|
http://localhost:8080/jsonrpc). You need to make sure that the RPC
|
||||||
|
API is enabled on your Kodi instance - you can enable it from the
|
||||||
:param http_port: Kodi JSON RPC web port. Remember to enable "Allow remote control via HTTP"
|
settings.
|
||||||
in Kodi service settings -> advanced configuration and "Allow remote control from applications"
|
|
||||||
on this system and, optionally, on other systems if the Kodi server is on another machine
|
|
||||||
:type http_port: int
|
|
||||||
|
|
||||||
:param websocket_port: Kodi JSON RPC websocket port, used to receive player events
|
:param websocket_port: Kodi JSON RPC websocket port, used to receive player events
|
||||||
:type websocket_port: int
|
|
||||||
|
|
||||||
:param username: Kodi username (optional)
|
:param username: Kodi username (optional)
|
||||||
:type username: str
|
|
||||||
|
|
||||||
:param password: Kodi password (optional)
|
:param password: Kodi password (optional)
|
||||||
:type password: str
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
self.host = host
|
self.url = rpc_url
|
||||||
self.http_port = http_port
|
host, port = kwargs.get('host'), kwargs.get('port', 8080)
|
||||||
|
|
||||||
|
if host and port:
|
||||||
|
self.logger.warning('host and port are deprecated, use rpc_url instead')
|
||||||
|
self.url = f'http://{host}:{port}/jsonrpc'
|
||||||
|
|
||||||
|
self.host = urlparse(self.url).hostname
|
||||||
self.websocket_port = websocket_port
|
self.websocket_port = websocket_port
|
||||||
self.url = 'http://{}:{}/jsonrpc'.format(host, http_port)
|
self.websocket_url = f'ws://{self.host}:{websocket_port}/jsonrpc'
|
||||||
self.websocket_url = 'ws://{}:{}/jsonrpc'.format(host, websocket_port)
|
|
||||||
self.username = username
|
self.username = username
|
||||||
self.password = password
|
self.password = password
|
||||||
self._ws = None
|
self._ws = None
|
||||||
|
@ -113,7 +109,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
def _on_ws_msg(self):
|
def _on_ws_msg(self):
|
||||||
def hndl(*args):
|
def hndl(*args):
|
||||||
msg = args[1] if len(args) > 1 else args[0]
|
msg = args[1] if len(args) > 1 else args[0]
|
||||||
self.logger.info("Received Kodi message: {}".format(msg))
|
self.logger.info("Received Kodi message: %s", msg)
|
||||||
msg = json.loads(msg)
|
msg = json.loads(msg)
|
||||||
method = msg.get('method')
|
method = msg.get('method')
|
||||||
|
|
||||||
|
@ -138,6 +134,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
elif method == 'Player.OnStop':
|
elif method == 'Player.OnStop':
|
||||||
player = msg.get('params', {}).get('data', {}).get('player', {})
|
player = msg.get('params', {}).get('data', {}).get('player', {})
|
||||||
self._post_event(MediaStopEvent, player_id=player.get('playerid'))
|
self._post_event(MediaStopEvent, player_id=player.get('playerid'))
|
||||||
|
self._clear_resource()
|
||||||
elif method == 'Player.OnSeek':
|
elif method == 'Player.OnSeek':
|
||||||
player = msg.get('params', {}).get('data', {}).get('player', {})
|
player = msg.get('params', {}).get('data', {}).get('player', {})
|
||||||
position = self._time_obj_to_pos(player.get('seekoffset'))
|
position = self._time_obj_to_pos(player.get('seekoffset'))
|
||||||
|
@ -153,7 +150,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
def _on_ws_error(self):
|
def _on_ws_error(self):
|
||||||
def hndl(*args):
|
def hndl(*args):
|
||||||
error = args[1] if len(args) > 1 else args[0]
|
error = args[1] if len(args) > 1 else args[0]
|
||||||
self.logger.warning("Kodi websocket connection error: {}".format(error))
|
self.logger.warning("Kodi websocket connection error: %s", error)
|
||||||
|
|
||||||
return hndl
|
return hndl
|
||||||
|
|
||||||
|
@ -171,25 +168,28 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
status['result'] = result.get('result')
|
status['result'] = result.get('result')
|
||||||
return status, result.get('error')
|
return status, result.get('error')
|
||||||
|
|
||||||
|
def _clear_resource(self):
|
||||||
|
if self._latest_resource:
|
||||||
|
self._latest_resource.close()
|
||||||
|
self._latest_resource = None
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def play(self, resource, *args, **kwargs):
|
def play(self, resource: str, **kwargs):
|
||||||
"""
|
"""
|
||||||
Open and play the specified file or URL
|
Open and play the specified file or URL
|
||||||
|
|
||||||
:param resource: URL or path to the media to be played
|
:param resource: URL or path to the media to be played
|
||||||
"""
|
"""
|
||||||
|
media = self._latest_resource = self._get_resource(resource, **kwargs)
|
||||||
if resource.startswith('file://'):
|
media.open(**kwargs)
|
||||||
resource = resource[7:]
|
result = self._get_kodi().Player.Open(item={'file': media.resource})
|
||||||
|
|
||||||
result = self._get_kodi().Player.Open(item={'file': resource})
|
|
||||||
if self.volume:
|
if self.volume:
|
||||||
self.set_volume(volume=int(self.volume))
|
self.set_volume(volume=int(self.volume))
|
||||||
|
|
||||||
return self._build_result(result)
|
return self._build_result(result)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def pause(self, player_id=None, *args, **kwargs):
|
def pause(self, player_id=None, **_):
|
||||||
"""
|
"""
|
||||||
Play/pause the current media
|
Play/pause the current media
|
||||||
"""
|
"""
|
||||||
|
@ -212,7 +212,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def get_movies(self, *args, **kwargs):
|
def get_movies(self, **_):
|
||||||
"""
|
"""
|
||||||
Get the list of movies on the Kodi server
|
Get the list of movies on the Kodi server
|
||||||
"""
|
"""
|
||||||
|
@ -221,7 +221,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def stop(self, player_id=None, *args, **kwargs):
|
def stop(self, player_id=None, **_):
|
||||||
"""
|
"""
|
||||||
Stop the current media
|
Stop the current media
|
||||||
"""
|
"""
|
||||||
|
@ -232,10 +232,11 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return None, 'No active players found'
|
return None, 'No active players found'
|
||||||
|
|
||||||
result = self._get_kodi().Player.Stop(playerid=player_id)
|
result = self._get_kodi().Player.Stop(playerid=player_id)
|
||||||
|
self._clear_resource()
|
||||||
return self._build_result(result)
|
return self._build_result(result)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def notify(self, title, message, *args, **kwargs):
|
def notify(self, title, message, **_):
|
||||||
"""
|
"""
|
||||||
Send a notification to the Kodi UI
|
Send a notification to the Kodi UI
|
||||||
"""
|
"""
|
||||||
|
@ -244,7 +245,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def left(self, *args, **kwargs):
|
def left(self, **_):
|
||||||
"""
|
"""
|
||||||
Simulate a left input event
|
Simulate a left input event
|
||||||
"""
|
"""
|
||||||
|
@ -253,7 +254,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def right(self, *args, **kwargs):
|
def right(self, **_):
|
||||||
"""
|
"""
|
||||||
Simulate a right input event
|
Simulate a right input event
|
||||||
"""
|
"""
|
||||||
|
@ -262,7 +263,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def up(self, *args, **kwargs):
|
def up(self, **_):
|
||||||
"""
|
"""
|
||||||
Simulate an up input event
|
Simulate an up input event
|
||||||
"""
|
"""
|
||||||
|
@ -271,7 +272,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def down(self, *args, **kwargs):
|
def down(self, **_):
|
||||||
"""
|
"""
|
||||||
Simulate a down input event
|
Simulate a down input event
|
||||||
"""
|
"""
|
||||||
|
@ -280,7 +281,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def back_btn(self, *args, **kwargs):
|
def back_btn(self, **_):
|
||||||
"""
|
"""
|
||||||
Simulate a back input event
|
Simulate a back input event
|
||||||
"""
|
"""
|
||||||
|
@ -289,7 +290,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def select(self, *args, **kwargs):
|
def select(self, **_):
|
||||||
"""
|
"""
|
||||||
Simulate a select input event
|
Simulate a select input event
|
||||||
"""
|
"""
|
||||||
|
@ -298,7 +299,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def send_text(self, text, *args, **kwargs):
|
def send_text(self, text, **_):
|
||||||
"""
|
"""
|
||||||
Simulate a send_text input event
|
Simulate a send_text input event
|
||||||
|
|
||||||
|
@ -310,13 +311,13 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def get_volume(self, *args, **kwargs):
|
def get_volume(self, **_):
|
||||||
result = self._get_kodi().Application.GetProperties(properties=['volume'])
|
result = self._get_kodi().Application.GetProperties(properties=['volume'])
|
||||||
|
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def volup(self, step=10.0, *args, **kwargs):
|
def volup(self, step=10.0, **_):
|
||||||
"""Volume up (default: +10%)"""
|
"""Volume up (default: +10%)"""
|
||||||
volume = (
|
volume = (
|
||||||
self._get_kodi()
|
self._get_kodi()
|
||||||
|
@ -331,7 +332,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return self._build_result(result)
|
return self._build_result(result)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def voldown(self, step=10.0, *args, **kwargs):
|
def voldown(self, step=10.0, **_):
|
||||||
"""Volume down (default: -10%)"""
|
"""Volume down (default: -10%)"""
|
||||||
volume = (
|
volume = (
|
||||||
self._get_kodi()
|
self._get_kodi()
|
||||||
|
@ -346,7 +347,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return self._build_result(result)
|
return self._build_result(result)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def set_volume(self, volume, *args, **kwargs):
|
def set_volume(self, volume, **_):
|
||||||
"""
|
"""
|
||||||
Set the application volume
|
Set the application volume
|
||||||
|
|
||||||
|
@ -358,7 +359,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return self._build_result(result)
|
return self._build_result(result)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def mute(self, *args, **kwargs):
|
def mute(self, **_):
|
||||||
"""
|
"""
|
||||||
Mute/unmute the application
|
Mute/unmute the application
|
||||||
"""
|
"""
|
||||||
|
@ -374,7 +375,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return self._build_result(result)
|
return self._build_result(result)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def is_muted(self, *args, **kwargs):
|
def is_muted(self, **_):
|
||||||
"""
|
"""
|
||||||
Return the muted status of the application
|
Return the muted status of the application
|
||||||
"""
|
"""
|
||||||
|
@ -383,7 +384,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result')
|
return result.get('result')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def scan_video_library(self, *args, **kwargs):
|
def scan_video_library(self, **_):
|
||||||
"""
|
"""
|
||||||
Scan the video library
|
Scan the video library
|
||||||
"""
|
"""
|
||||||
|
@ -392,7 +393,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def scan_audio_library(self, *args, **kwargs):
|
def scan_audio_library(self, **_):
|
||||||
"""
|
"""
|
||||||
Scan the audio library
|
Scan the audio library
|
||||||
"""
|
"""
|
||||||
|
@ -401,7 +402,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def clean_video_library(self, *args, **kwargs):
|
def clean_video_library(self, **_):
|
||||||
"""
|
"""
|
||||||
Clean the video library
|
Clean the video library
|
||||||
"""
|
"""
|
||||||
|
@ -410,7 +411,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def clean_audio_library(self, *args, **kwargs):
|
def clean_audio_library(self, **_):
|
||||||
"""
|
"""
|
||||||
Clean the audio library
|
Clean the audio library
|
||||||
"""
|
"""
|
||||||
|
@ -419,16 +420,17 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def quit(self, *args, **kwargs):
|
def quit(self, **_):
|
||||||
"""
|
"""
|
||||||
Quit the application
|
Quit the application
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = self._get_kodi().Application.Quit()
|
result = self._get_kodi().Application.Quit()
|
||||||
|
self._clear_resource()
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def get_songs(self, *args, **kwargs):
|
def get_songs(self, **_):
|
||||||
"""
|
"""
|
||||||
Get the list of songs in the audio library
|
Get the list of songs in the audio library
|
||||||
"""
|
"""
|
||||||
|
@ -437,7 +439,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def get_artists(self, *args, **kwargs):
|
def get_artists(self, **_):
|
||||||
"""
|
"""
|
||||||
Get the list of artists in the audio library
|
Get the list of artists in the audio library
|
||||||
"""
|
"""
|
||||||
|
@ -446,7 +448,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def get_albums(self, *args, **kwargs):
|
def get_albums(self, **_):
|
||||||
"""
|
"""
|
||||||
Get the list of albums in the audio library
|
Get the list of albums in the audio library
|
||||||
"""
|
"""
|
||||||
|
@ -455,7 +457,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def fullscreen(self, *args, **kwargs):
|
def fullscreen(self, **_):
|
||||||
"""
|
"""
|
||||||
Set/unset fullscreen mode
|
Set/unset fullscreen mode
|
||||||
"""
|
"""
|
||||||
|
@ -471,7 +473,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def shuffle(self, player_id=None, shuffle=None, *args, **kwargs):
|
def shuffle(self, player_id=None, shuffle=None, **_):
|
||||||
"""
|
"""
|
||||||
Set/unset shuffle mode
|
Set/unset shuffle mode
|
||||||
"""
|
"""
|
||||||
|
@ -495,7 +497,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return result.get('result'), result.get('error')
|
return result.get('result'), result.get('error')
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def repeat(self, player_id=None, repeat=None, *args, **kwargs):
|
def repeat(self, player_id=None, repeat=None, **_):
|
||||||
"""
|
"""
|
||||||
Set/unset repeat mode
|
Set/unset repeat mode
|
||||||
"""
|
"""
|
||||||
|
@ -543,7 +545,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
)
|
)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def seek(self, position, player_id=None, *args, **kwargs):
|
def seek(self, position, player_id=None, **_):
|
||||||
"""
|
"""
|
||||||
Move to the specified time position in seconds
|
Move to the specified time position in seconds
|
||||||
|
|
||||||
|
@ -573,7 +575,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
return self.seek(*args, position=position, player_id=player_id, **kwargs)
|
return self.seek(*args, position=position, player_id=player_id, **kwargs)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def back(self, offset=30, player_id=None, *args, **kwargs):
|
def back(self, offset=30, player_id=None, **_):
|
||||||
"""
|
"""
|
||||||
Move the player execution backward by delta_seconds
|
Move the player execution backward by delta_seconds
|
||||||
|
|
||||||
|
@ -594,11 +596,11 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
.get('time', {})
|
.get('time', {})
|
||||||
)
|
)
|
||||||
|
|
||||||
position = self._time_obj_to_pos(position)
|
position = self._time_obj_to_pos(position) - offset
|
||||||
return self.seek(player_id=player_id, position=position)
|
return self.seek(player_id=player_id, position=position)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def forward(self, offset=30, player_id=None, *args, **kwargs):
|
def forward(self, offset=30, player_id=None, **_):
|
||||||
"""
|
"""
|
||||||
Move the player execution forward by delta_seconds
|
Move the player execution forward by delta_seconds
|
||||||
|
|
||||||
|
@ -619,7 +621,7 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
.get('time', {})
|
.get('time', {})
|
||||||
)
|
)
|
||||||
|
|
||||||
position = self._time_obj_to_pos(position)
|
position = self._time_obj_to_pos(position) + offset
|
||||||
return self.seek(player_id=player_id, position=position)
|
return self.seek(player_id=player_id, position=position)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -715,20 +717,28 @@ class MediaKodiPlugin(MediaPlugin):
|
||||||
)
|
)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
def is_playing(self, *args, **kwargs):
|
def is_playing(self, *_, **__):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def load(self, resource, *args, **kwargs):
|
def load(self, *_, **__):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_local_media(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_local_pipe(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
|
@ -4,7 +4,7 @@ import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import dataclass
|
||||||
from multiprocessing import Process, Queue, RLock
|
from multiprocessing import Process, Queue, RLock
|
||||||
from queue import Empty
|
from queue import Empty
|
||||||
from typing import Any, Collection, Dict, List, Optional
|
from typing import Any, Collection, Dict, List, Optional
|
||||||
|
@ -395,7 +395,7 @@ class MediaMplayerPlugin(MediaPlugin):
|
||||||
resource: str,
|
resource: str,
|
||||||
subtitles: Optional[str] = None,
|
subtitles: Optional[str] = None,
|
||||||
mplayer_args: Optional[List[str]] = None,
|
mplayer_args: Optional[List[str]] = None,
|
||||||
**_,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Play a resource.
|
Play a resource.
|
||||||
|
@ -412,11 +412,11 @@ class MediaMplayerPlugin(MediaPlugin):
|
||||||
if subs:
|
if subs:
|
||||||
mplayer_args = list(mplayer_args or []) + ['-sub', subs]
|
mplayer_args = list(mplayer_args or []) + ['-sub', subs]
|
||||||
|
|
||||||
resource = self._get_resource(resource)
|
media = self._latest_resource = self._get_resource(resource, **kwargs)
|
||||||
if resource.startswith('file://'):
|
media.open(**kwargs)
|
||||||
resource = resource[7:]
|
self.logger.debug('Playing media: %s', media)
|
||||||
|
self._exec('loadfile', media.resource, mplayer_args=mplayer_args)
|
||||||
|
|
||||||
self._exec('loadfile', resource, mplayer_args=mplayer_args)
|
|
||||||
if self.volume:
|
if self.volume:
|
||||||
self.set_volume(volume=self.volume)
|
self.set_volume(volume=self.volume)
|
||||||
|
|
||||||
|
@ -435,6 +435,10 @@ class MediaMplayerPlugin(MediaPlugin):
|
||||||
|
|
||||||
def _cleanup(self):
|
def _cleanup(self):
|
||||||
with self._cleanup_lock:
|
with self._cleanup_lock:
|
||||||
|
if self._latest_resource:
|
||||||
|
self._latest_resource.close()
|
||||||
|
self._latest_resource = None
|
||||||
|
|
||||||
if self._player:
|
if self._player:
|
||||||
self._player.terminate()
|
self._player.terminate()
|
||||||
self._player.wait()
|
self._player.wait()
|
||||||
|
@ -666,7 +670,7 @@ class MediaMplayerPlugin(MediaPlugin):
|
||||||
status.update(
|
status.update(
|
||||||
{
|
{
|
||||||
k: v
|
k: v
|
||||||
for k, v in asdict(self._latest_resource).items()
|
for k, v in self._latest_resource.to_dict().items()
|
||||||
if v is not None
|
if v is not None
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -688,7 +692,7 @@ class MediaMplayerPlugin(MediaPlugin):
|
||||||
property,
|
property,
|
||||||
prefix='pausing_keep_force',
|
prefix='pausing_keep_force',
|
||||||
wait_for_response=True,
|
wait_for_response=True,
|
||||||
*args,
|
*args, # noqa: B026
|
||||||
)
|
)
|
||||||
or {}
|
or {}
|
||||||
)
|
)
|
||||||
|
@ -757,7 +761,7 @@ class MediaMplayerPlugin(MediaPlugin):
|
||||||
value,
|
value,
|
||||||
prefix='pausing_keep_force' if property != 'pause' else None,
|
prefix='pausing_keep_force' if property != 'pause' else None,
|
||||||
wait_for_response=True,
|
wait_for_response=True,
|
||||||
*args,
|
*args, # noqa: B026
|
||||||
)
|
)
|
||||||
or {}
|
or {}
|
||||||
)
|
)
|
||||||
|
@ -799,7 +803,7 @@ class MediaMplayerPlugin(MediaPlugin):
|
||||||
value,
|
value,
|
||||||
prefix='pausing_keep_force',
|
prefix='pausing_keep_force',
|
||||||
wait_for_response=True,
|
wait_for_response=True,
|
||||||
*args,
|
*args, # noqa: B026
|
||||||
)
|
)
|
||||||
or {}
|
or {}
|
||||||
)
|
)
|
||||||
|
@ -820,5 +824,9 @@ class MediaMplayerPlugin(MediaPlugin):
|
||||||
self.logger.debug('set_subtitles called with filename=%s', filename)
|
self.logger.debug('set_subtitles called with filename=%s', filename)
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_local_pipe(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import os
|
import os
|
||||||
from dataclasses import asdict
|
|
||||||
from typing import Any, Dict, Optional, Type
|
from typing import Any, Dict, Optional, Type
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from platypush.plugins import action
|
from platypush.plugins import action
|
||||||
from platypush.plugins.media import PlayerState, MediaPlugin
|
from platypush.plugins.media import PlayerState, MediaPlugin
|
||||||
|
from platypush.plugins.media._resource import MediaResource, YoutubeMediaResource
|
||||||
from platypush.message.event.media import (
|
from platypush.message.event.media import (
|
||||||
MediaEvent,
|
MediaEvent,
|
||||||
MediaPlayEvent,
|
MediaPlayEvent,
|
||||||
|
@ -23,7 +23,6 @@ class MediaMpvPlugin(MediaPlugin):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_default_mpv_args = {
|
_default_mpv_args = {
|
||||||
'ytdl': True,
|
|
||||||
'start_event_thread': True,
|
'start_event_thread': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,16 +48,42 @@ class MediaMpvPlugin(MediaPlugin):
|
||||||
self._player = None
|
self._player = None
|
||||||
self._latest_state = PlayerState.STOP
|
self._latest_state = PlayerState.STOP
|
||||||
|
|
||||||
def _init_mpv(self, args=None):
|
def _init_mpv(
|
||||||
|
self,
|
||||||
|
args: Optional[dict] = None,
|
||||||
|
resource: Optional[MediaResource] = None,
|
||||||
|
youtube_format: Optional[str] = None,
|
||||||
|
youtube_audio_format: Optional[str] = None,
|
||||||
|
only_audio: bool = False,
|
||||||
|
):
|
||||||
import mpv
|
import mpv
|
||||||
|
|
||||||
mpv_args = self.args.copy()
|
mpv_args: dict = {**self.args}
|
||||||
|
|
||||||
|
if isinstance(resource, YoutubeMediaResource):
|
||||||
|
youtube_format = youtube_format or self.youtube_format
|
||||||
|
if only_audio:
|
||||||
|
youtube_format = (
|
||||||
|
youtube_audio_format or self.youtube_audio_format or youtube_format
|
||||||
|
)
|
||||||
|
|
||||||
|
mpv_args.update(
|
||||||
|
{
|
||||||
|
'ytdl': True,
|
||||||
|
'ytdl_format': youtube_format,
|
||||||
|
'script_opts': f'ytdl_hook-ytdl-path={self._ytdl}',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if args:
|
if args:
|
||||||
mpv_args.update(args)
|
mpv_args.update(args)
|
||||||
|
|
||||||
|
mpv_args.pop('metadata', None)
|
||||||
|
|
||||||
for k, v in self._env.items():
|
for k, v in self._env.items():
|
||||||
os.environ[k] = v
|
os.environ[k] = v
|
||||||
|
|
||||||
|
self.logger.debug('Initializing mpv with args: %s', mpv_args)
|
||||||
self._player = mpv.MPV(**mpv_args)
|
self._player = mpv.MPV(**mpv_args)
|
||||||
self._player._event_callbacks += [self._event_callback()]
|
self._player._event_callbacks += [self._event_callback()]
|
||||||
|
|
||||||
|
@ -159,6 +184,10 @@ class MediaMpvPlugin(MediaPlugin):
|
||||||
*_,
|
*_,
|
||||||
subtitles: Optional[str] = None,
|
subtitles: Optional[str] = None,
|
||||||
fullscreen: Optional[bool] = None,
|
fullscreen: Optional[bool] = None,
|
||||||
|
youtube_format: Optional[str] = None,
|
||||||
|
youtube_audio_format: Optional[str] = None,
|
||||||
|
only_audio: bool = False,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
**args,
|
**args,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
@ -168,20 +197,31 @@ class MediaMpvPlugin(MediaPlugin):
|
||||||
:param subtitles: Path to optional subtitle file
|
:param subtitles: Path to optional subtitle file
|
||||||
:param args: Extra runtime arguments that will be passed to the
|
:param args: Extra runtime arguments that will be passed to the
|
||||||
mpv executable as a key-value dict (keys without `--` prefix)
|
mpv executable as a key-value dict (keys without `--` prefix)
|
||||||
|
:param fullscreen: Override the default fullscreen setting.
|
||||||
|
:param youtube_format: Override the default youtube format setting.
|
||||||
|
:param youtube_audio_format: Override the default youtube audio format
|
||||||
|
setting.
|
||||||
|
:param only_audio: Set to True if you want to play only the audio of a
|
||||||
|
youtube video.
|
||||||
|
:param metadata: Optional metadata to attach to the resource.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self._post_event(MediaPlayRequestEvent, resource=resource)
|
self._post_event(MediaPlayRequestEvent, resource=resource)
|
||||||
if fullscreen is not None:
|
if fullscreen is not None:
|
||||||
args['fs'] = fullscreen
|
args['fs'] = fullscreen
|
||||||
|
|
||||||
self._init_mpv(args)
|
media = self._latest_resource = self._get_resource(resource, metadata=metadata)
|
||||||
|
self._init_mpv(
|
||||||
resource = self._get_resource(resource)
|
args,
|
||||||
if resource.startswith('file://'):
|
resource=media,
|
||||||
resource = resource[7:]
|
youtube_format=youtube_format,
|
||||||
|
youtube_audio_format=youtube_audio_format,
|
||||||
|
only_audio=only_audio,
|
||||||
|
)
|
||||||
|
|
||||||
assert self._cur_player, 'The player is not ready'
|
assert self._cur_player, 'The player is not ready'
|
||||||
self._cur_player.play(resource)
|
self._cur_player.play(media.resource or media.url)
|
||||||
|
|
||||||
if self.volume:
|
if self.volume:
|
||||||
self.set_volume(volume=self.volume)
|
self.set_volume(volume=self.volume)
|
||||||
if subtitles:
|
if subtitles:
|
||||||
|
@ -498,7 +538,7 @@ class MediaMpvPlugin(MediaPlugin):
|
||||||
status.update(
|
status.update(
|
||||||
{
|
{
|
||||||
k: v
|
k: v
|
||||||
for k, v in asdict(self._latest_resource).items()
|
for k, v in self._latest_resource.to_dict().items()
|
||||||
if v is not None
|
if v is not None
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -516,11 +556,9 @@ class MediaMpvPlugin(MediaPlugin):
|
||||||
self._latest_state = self._state
|
self._latest_state = self._state
|
||||||
return status
|
return status
|
||||||
|
|
||||||
def _get_resource(self, resource):
|
@property
|
||||||
if self._is_youtube_resource(resource):
|
def supports_local_pipe(self) -> bool:
|
||||||
return resource # mpv can handle YouTube streaming natively
|
return False
|
||||||
|
|
||||||
return super()._get_resource(resource)
|
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
|
@ -1,446 +0,0 @@
|
||||||
from dataclasses import asdict
|
|
||||||
import enum
|
|
||||||
import threading
|
|
||||||
from typing import Collection, Optional
|
|
||||||
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
from platypush.context import get_bus
|
|
||||||
from platypush.plugins.media import MediaPlugin, PlayerState
|
|
||||||
from platypush.message.event.media import (
|
|
||||||
MediaPlayEvent,
|
|
||||||
MediaPauseEvent,
|
|
||||||
MediaStopEvent,
|
|
||||||
MediaSeekEvent,
|
|
||||||
MediaPlayRequestEvent,
|
|
||||||
)
|
|
||||||
|
|
||||||
from platypush.plugins import action
|
|
||||||
|
|
||||||
|
|
||||||
class PlayerEvent(enum.Enum):
|
|
||||||
"""
|
|
||||||
Supported player events.
|
|
||||||
"""
|
|
||||||
|
|
||||||
STOP = 'stop'
|
|
||||||
PLAY = 'play'
|
|
||||||
PAUSE = 'pause'
|
|
||||||
|
|
||||||
|
|
||||||
class MediaOmxplayerPlugin(MediaPlugin):
|
|
||||||
"""
|
|
||||||
Plugin to control video and media playback using OMXPlayer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, args: Optional[Collection[str]] = None, timeout: float = 20.0, **kwargs
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
:param args: Arguments that will be passed to the OMXPlayer constructor
|
|
||||||
(e.g. subtitles, volume, start position, window size etc.) see
|
|
||||||
https://github.com/popcornmix/omxplayer#synopsis and
|
|
||||||
https://python-omxplayer-wrapper.readthedocs.io/en/latest/omxplayer/#omxplayer.player.OMXPlayer
|
|
||||||
:param timeout: How long the plugin should wait for a video to start upon play request (default: 20 seconds).
|
|
||||||
"""
|
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
|
|
||||||
if args is None:
|
|
||||||
args = []
|
|
||||||
|
|
||||||
self.args = args
|
|
||||||
self.timeout = timeout
|
|
||||||
self._player = None
|
|
||||||
self._handlers = {e.value: [] for e in PlayerEvent}
|
|
||||||
self._play_started = threading.Event()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def play(self, *args, resource=None, subtitles=None, **_):
|
|
||||||
"""
|
|
||||||
Play or resume playing a resource.
|
|
||||||
|
|
||||||
:param resource: Resource to play. Supported types:
|
|
||||||
|
|
||||||
* Local files (format: ``file://<path>/<file>``)
|
|
||||||
* Remote videos (format: ``https://<url>/<resource>``)
|
|
||||||
* YouTube videos (format: ``https://www.youtube.com/watch?v=<id>``)
|
|
||||||
* Torrents (format: Magnet links, Torrent URLs or local Torrent files)
|
|
||||||
|
|
||||||
:param subtitles: Subtitles file
|
|
||||||
"""
|
|
||||||
if not resource:
|
|
||||||
if not self._player:
|
|
||||||
self.logger.warning('No OMXPlayer instances running')
|
|
||||||
else:
|
|
||||||
self._player.play()
|
|
||||||
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
self._play_started.clear()
|
|
||||||
self._post_event(MediaPlayRequestEvent, resource=resource)
|
|
||||||
|
|
||||||
if subtitles:
|
|
||||||
args += ('--subtitles', subtitles)
|
|
||||||
|
|
||||||
resource = self._get_resource(resource)
|
|
||||||
if self._player:
|
|
||||||
try:
|
|
||||||
self._player.stop()
|
|
||||||
self._player = None
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.exception(e)
|
|
||||||
self.logger.warning(
|
|
||||||
'Unable to stop a previously running instance '
|
|
||||||
+ 'of OMXPlayer, trying to play anyway'
|
|
||||||
)
|
|
||||||
|
|
||||||
from dbus import DBusException
|
|
||||||
|
|
||||||
try:
|
|
||||||
from omxplayer import OMXPlayer
|
|
||||||
|
|
||||||
self._player = OMXPlayer(resource, args=self.args)
|
|
||||||
except DBusException as e:
|
|
||||||
self.logger.warning(
|
|
||||||
'DBus connection failed: you will probably not '
|
|
||||||
+ 'be able to control the media'
|
|
||||||
)
|
|
||||||
self.logger.exception(e)
|
|
||||||
|
|
||||||
self._post_event(MediaPlayEvent, resource=resource)
|
|
||||||
self._init_player_handlers()
|
|
||||||
if not self._play_started.wait(timeout=self.timeout):
|
|
||||||
self.logger.warning(
|
|
||||||
f'The player has not sent a play started event within {self.timeout}'
|
|
||||||
)
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def pause(self):
|
|
||||||
"""Pause the playback"""
|
|
||||||
if self._player:
|
|
||||||
self._player.play_pause()
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def stop(self):
|
|
||||||
"""Stop the playback (same as quit)"""
|
|
||||||
return self.quit()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def quit(self):
|
|
||||||
"""Quit the player"""
|
|
||||||
from omxplayer.player import OMXPlayerDeadError
|
|
||||||
|
|
||||||
if self._player:
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
self._player.stop()
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f'Could not stop player: {str(e)}')
|
|
||||||
|
|
||||||
self._player.quit()
|
|
||||||
except OMXPlayerDeadError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
self._player = None
|
|
||||||
|
|
||||||
return {'status': 'stop'}
|
|
||||||
|
|
||||||
def get_volume(self) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
:return: The player volume in percentage [0, 100].
|
|
||||||
"""
|
|
||||||
if self._player:
|
|
||||||
return self._player.volume() * 100
|
|
||||||
|
|
||||||
@action
|
|
||||||
def voldown(self, step=10.0):
|
|
||||||
"""
|
|
||||||
Decrease the volume.
|
|
||||||
|
|
||||||
:param step: Volume decrease step between 0 and 100 (default: 10%).
|
|
||||||
:type step: float
|
|
||||||
"""
|
|
||||||
if self._player:
|
|
||||||
vol = self.get_volume()
|
|
||||||
if vol is not None:
|
|
||||||
self.set_volume(max(0, vol - step))
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def volup(self, step=10.0):
|
|
||||||
"""
|
|
||||||
Increase the volume.
|
|
||||||
|
|
||||||
:param step: Volume increase step between 0 and 100 (default: 10%).
|
|
||||||
:type step: float
|
|
||||||
"""
|
|
||||||
if self._player:
|
|
||||||
vol = self.get_volume()
|
|
||||||
if vol is not None:
|
|
||||||
self.set_volume(min(100, vol + step))
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def back(self, offset=30):
|
|
||||||
"""Back by (default: 30) seconds"""
|
|
||||||
if self._player:
|
|
||||||
self._player.seek(-offset)
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def forward(self, offset=30):
|
|
||||||
"""Forward by (default: 30) seconds"""
|
|
||||||
if self._player:
|
|
||||||
self._player.seek(offset)
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def next(self):
|
|
||||||
"""Play the next track/video"""
|
|
||||||
if self._player:
|
|
||||||
self._player.stop()
|
|
||||||
|
|
||||||
if self._videos_queue:
|
|
||||||
video = self._videos_queue.pop(0)
|
|
||||||
self.play(video)
|
|
||||||
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def hide_subtitles(self):
|
|
||||||
"""Hide the subtitles"""
|
|
||||||
if self._player:
|
|
||||||
self._player.hide_subtitles()
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def hide_video(self):
|
|
||||||
"""Hide the video"""
|
|
||||||
if self._player:
|
|
||||||
self._player.hide_video()
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def is_playing(self, *_, **__) -> bool:
|
|
||||||
"""
|
|
||||||
:returns: True if it's playing, False otherwise
|
|
||||||
"""
|
|
||||||
return self._player.is_playing() if self._player else False
|
|
||||||
|
|
||||||
@action
|
|
||||||
def load(self, resource: str, *_, pause: bool = False, **__):
|
|
||||||
"""
|
|
||||||
Load a resource/video in the player.
|
|
||||||
|
|
||||||
:param resource: URL or filename to load
|
|
||||||
:param pause: If set, load the video in paused mode (default: False)
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self._player:
|
|
||||||
self._player.load(resource, pause=pause)
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def metadata(self):
|
|
||||||
"""Get the metadata of the current video"""
|
|
||||||
if self._player:
|
|
||||||
return self._player.metadata()
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def mute(self, *_, **__):
|
|
||||||
"""Mute the player"""
|
|
||||||
if self._player:
|
|
||||||
self._player.mute()
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def unmute(self, *_, **__):
|
|
||||||
"""Unmute the player"""
|
|
||||||
if self._player:
|
|
||||||
self._player.unmute()
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def seek(self, position: float, **__):
|
|
||||||
"""
|
|
||||||
Seek to the specified number of seconds from the start.
|
|
||||||
|
|
||||||
:param position: Number of seconds from the start
|
|
||||||
"""
|
|
||||||
if self._player:
|
|
||||||
self._player.set_position(position)
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def set_position(self, position: float, **__):
|
|
||||||
"""
|
|
||||||
Seek to the specified number of seconds from the start (same as :meth:`.seek`).
|
|
||||||
|
|
||||||
:param position: Number of seconds from the start
|
|
||||||
"""
|
|
||||||
return self.seek(position)
|
|
||||||
|
|
||||||
@action
|
|
||||||
def set_volume(self, volume: float, *_, **__):
|
|
||||||
"""
|
|
||||||
Set the volume
|
|
||||||
|
|
||||||
:param volume: Volume value between 0 and 100
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self._player:
|
|
||||||
self._player.set_volume(volume / 100)
|
|
||||||
return self.status()
|
|
||||||
|
|
||||||
@action
|
|
||||||
def status(self):
|
|
||||||
"""
|
|
||||||
Get the current player state.
|
|
||||||
|
|
||||||
:returns: A dictionary containing the current state.
|
|
||||||
|
|
||||||
Format::
|
|
||||||
|
|
||||||
output = {
|
|
||||||
"duration": Duration in seconds,
|
|
||||||
"filename": Media filename,
|
|
||||||
"fullscreen": true or false,
|
|
||||||
"mute": true or false,
|
|
||||||
"path": Media path
|
|
||||||
"pause": true or false,
|
|
||||||
"position": Position in seconds
|
|
||||||
"seekable": true or false
|
|
||||||
"state": play, pause or stop
|
|
||||||
"title": Media title
|
|
||||||
"url": Media url
|
|
||||||
"volume": Volume between 0 and 100
|
|
||||||
"volume_max": 100,
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
from omxplayer.player import OMXPlayerDeadError
|
|
||||||
from dbus import DBusException
|
|
||||||
|
|
||||||
if not self._player:
|
|
||||||
return {'state': PlayerState.STOP.value}
|
|
||||||
|
|
||||||
try:
|
|
||||||
state = self._player.playback_status().lower()
|
|
||||||
except (OMXPlayerDeadError, DBusException) as e:
|
|
||||||
self.logger.warning('Could not retrieve player status: %s', e)
|
|
||||||
if isinstance(e, OMXPlayerDeadError):
|
|
||||||
self._player = None
|
|
||||||
|
|
||||||
return {'state': PlayerState.STOP.value}
|
|
||||||
|
|
||||||
if state == 'playing':
|
|
||||||
state = PlayerState.PLAY.value
|
|
||||||
elif state == 'stopped':
|
|
||||||
state = PlayerState.STOP.value
|
|
||||||
elif state == 'paused':
|
|
||||||
state = PlayerState.PAUSE.value
|
|
||||||
|
|
||||||
status = {
|
|
||||||
'duration': self._player.duration(),
|
|
||||||
'filename': urllib.parse.unquote(self._player.get_source()).split('/')[-1]
|
|
||||||
if self._player.get_source().startswith('file://')
|
|
||||||
else None,
|
|
||||||
'fullscreen': self._player.fullscreen(),
|
|
||||||
'mute': self._player._is_muted,
|
|
||||||
'path': self._player.get_source(),
|
|
||||||
'pause': state == PlayerState.PAUSE.value,
|
|
||||||
'position': max(0, self._player.position()),
|
|
||||||
'seekable': self._player.can_seek(),
|
|
||||||
'state': state,
|
|
||||||
'title': urllib.parse.unquote(self._player.get_source()).split('/')[-1]
|
|
||||||
if self._player.get_source().startswith('file://')
|
|
||||||
else None,
|
|
||||||
'url': self._player.get_source(),
|
|
||||||
'volume': self.get_volume(),
|
|
||||||
'volume_max': 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
if self._latest_resource:
|
|
||||||
status.update(
|
|
||||||
{
|
|
||||||
k: v
|
|
||||||
for k, v in asdict(self._latest_resource).items()
|
|
||||||
if v is not None
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return status
|
|
||||||
|
|
||||||
def add_handler(self, event_type, callback):
|
|
||||||
if event_type not in self._handlers.keys():
|
|
||||||
raise AttributeError(f'{event_type} is not a valid PlayerEvent type')
|
|
||||||
|
|
||||||
self._handlers[event_type].append(callback)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _post_event(evt_type, **evt):
|
|
||||||
bus = get_bus()
|
|
||||||
bus.post(evt_type(player='local', plugin='media.omxplayer', **evt))
|
|
||||||
|
|
||||||
def on_play(self):
|
|
||||||
def _f(player):
|
|
||||||
if self.volume and not self._play_started.is_set():
|
|
||||||
self.set_volume(self.volume)
|
|
||||||
self._play_started.set()
|
|
||||||
|
|
||||||
resource = player.get_source()
|
|
||||||
self._post_event(MediaPlayEvent, resource=resource)
|
|
||||||
for callback in self._handlers[PlayerEvent.PLAY.value]:
|
|
||||||
callback(resource)
|
|
||||||
|
|
||||||
return _f
|
|
||||||
|
|
||||||
def on_pause(self):
|
|
||||||
def _f(player):
|
|
||||||
resource = player.get_source()
|
|
||||||
self._post_event(MediaPauseEvent, resource=resource)
|
|
||||||
for callback in self._handlers[PlayerEvent.PAUSE.value]:
|
|
||||||
callback(resource)
|
|
||||||
|
|
||||||
return _f
|
|
||||||
|
|
||||||
def on_stop(self):
|
|
||||||
def _f(*_, **__):
|
|
||||||
self._post_event(MediaStopEvent)
|
|
||||||
for callback in self._handlers[PlayerEvent.STOP.value]:
|
|
||||||
callback()
|
|
||||||
|
|
||||||
return _f
|
|
||||||
|
|
||||||
def on_seek(self):
|
|
||||||
def _f(player, *_, **__):
|
|
||||||
self._post_event(MediaSeekEvent, position=player.position())
|
|
||||||
|
|
||||||
return _f
|
|
||||||
|
|
||||||
def _init_player_handlers(self):
|
|
||||||
if not self._player:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._player.playEvent += self.on_play()
|
|
||||||
self._player.pauseEvent += self.on_pause()
|
|
||||||
self._player.stopEvent += self.on_stop()
|
|
||||||
self._player.exitEvent += self.on_stop()
|
|
||||||
self._player.positionEvent += self.on_seek()
|
|
||||||
self._player.seekEvent += self.on_seek()
|
|
||||||
|
|
||||||
def toggle_subtitles(self, *_, **__):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def set_subtitles(self, *_, **__):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def remove_subtitles(self, *_, **__):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"manifest": {
|
|
||||||
"events": {},
|
|
||||||
"install": {
|
|
||||||
"pip": [
|
|
||||||
"omxplayer-wrapper",
|
|
||||||
"yt-dlp"
|
|
||||||
],
|
|
||||||
"apt": [
|
|
||||||
"omxplayer",
|
|
||||||
"yt-dlp"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"package": "platypush.plugins.media.omxplayer",
|
|
||||||
"type": "plugin"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,33 +0,0 @@
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .. import MediaPlugin
|
|
||||||
|
|
||||||
|
|
||||||
class MediaSearcher:
|
|
||||||
"""
|
|
||||||
Base class for media searchers
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, media_plugin: Optional[MediaPlugin] = None, **kwargs):
|
|
||||||
self.logger = logging.getLogger(self.__class__.__name__)
|
|
||||||
self.media_plugin = media_plugin
|
|
||||||
|
|
||||||
def search(self, query, *args, **kwargs):
|
|
||||||
raise NotImplementedError('The search method should be implemented ' +
|
|
||||||
'by a derived class')
|
|
||||||
|
|
||||||
|
|
||||||
from .local import LocalMediaSearcher
|
|
||||||
from .youtube import YoutubeMediaSearcher
|
|
||||||
from .torrent import TorrentMediaSearcher
|
|
||||||
from .plex import PlexMediaSearcher
|
|
||||||
from .jellyfin import JellyfinMediaSearcher
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'MediaSearcher', 'LocalMediaSearcher', 'TorrentMediaSearcher',
|
|
||||||
'YoutubeMediaSearcher', 'PlexMediaSearcher', 'JellyfinMediaSearcher',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
|
|
@ -1,61 +0,0 @@
|
||||||
from platypush.context import get_plugin
|
|
||||||
from platypush.plugins.media.search import MediaSearcher
|
|
||||||
|
|
||||||
|
|
||||||
class PlexMediaSearcher(MediaSearcher):
|
|
||||||
def search(self, query, **kwargs):
|
|
||||||
"""
|
|
||||||
Performs a Plex search using the configured :class:`platypush.plugins.media.plex.MediaPlexPlugin` instance if
|
|
||||||
it is available.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
plex = get_plugin('media.plex')
|
|
||||||
except RuntimeError:
|
|
||||||
return []
|
|
||||||
|
|
||||||
self.logger.info('Searching Plex for "{}"'.format(query))
|
|
||||||
results = []
|
|
||||||
|
|
||||||
for result in plex.search(title=query).output:
|
|
||||||
results.extend(self._flatten_result(result))
|
|
||||||
|
|
||||||
self.logger.info('{} Plex results found for the search query "{}"'.format(len(results), query))
|
|
||||||
return results
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _flatten_result(result):
|
|
||||||
def parse_part(media, part, episode=None, sub_media=None):
|
|
||||||
if 'episodes' in media:
|
|
||||||
del media['episodes']
|
|
||||||
|
|
||||||
return {
|
|
||||||
**{k: v for k, v in result.items() if k not in ['media', 'type']},
|
|
||||||
'media_type': result.get('type'),
|
|
||||||
'type': 'plex',
|
|
||||||
**{k: v for k, v in media.items() if k not in ['parts']},
|
|
||||||
**part,
|
|
||||||
'title': '{}{}{}'.format(
|
|
||||||
result.get('title', ''),
|
|
||||||
' [{}]'.format(episode['season_episode']) if (episode or {}).get('season_episode') else '',
|
|
||||||
' {}'.format(sub_media['title']) if (sub_media or {}).get('title') else '',
|
|
||||||
),
|
|
||||||
'summary': episode['summary'] if (episode or {}).get('summary') else media.get('summary'),
|
|
||||||
}
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
for media in result.get('media', []):
|
|
||||||
if 'episodes' in media:
|
|
||||||
for episode in media['episodes']:
|
|
||||||
for sub_media in episode.get('media', []):
|
|
||||||
for part in sub_media.get('parts', []):
|
|
||||||
results.append(parse_part(media=media, episode=episode, sub_media=sub_media, part=part))
|
|
||||||
else:
|
|
||||||
for part in media.get('parts', []):
|
|
||||||
results.append(parse_part(media=media, part=part))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
|
|
@ -1,11 +1,9 @@
|
||||||
from dataclasses import asdict
|
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import urllib.parse
|
|
||||||
from typing import Collection, Optional
|
from typing import Collection, Optional
|
||||||
|
|
||||||
from platypush.context import get_bus
|
from platypush.context import get_bus
|
||||||
from platypush.plugins.media import PlayerState, MediaPlugin
|
from platypush.plugins.media import PlayerState, MediaPlugin, MediaResource
|
||||||
from platypush.message.event.media import (
|
from platypush.message.event.media import (
|
||||||
MediaPlayEvent,
|
MediaPlayEvent,
|
||||||
MediaPlayRequestEvent,
|
MediaPlayRequestEvent,
|
||||||
|
@ -30,7 +28,7 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
args: Optional[Collection[str]] = None,
|
args: Optional[Collection[str]] = None,
|
||||||
fullscreen: bool = False,
|
fullscreen: bool = False,
|
||||||
volume: int = 100,
|
volume: int = 100,
|
||||||
**kwargs
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
:param args: List of extra arguments to pass to the VLC executable (e.g.
|
:param args: List of extra arguments to pass to the VLC executable (e.g.
|
||||||
|
@ -52,8 +50,6 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
self._default_fullscreen = fullscreen
|
self._default_fullscreen = fullscreen
|
||||||
self._default_volume = volume
|
self._default_volume = volume
|
||||||
self._on_stop_callbacks = []
|
self._on_stop_callbacks = []
|
||||||
self._title = None
|
|
||||||
self._filename = None
|
|
||||||
self._monitor_thread: Optional[threading.Thread] = None
|
self._monitor_thread: Optional[threading.Thread] = None
|
||||||
self._on_stop_event = threading.Event()
|
self._on_stop_event = threading.Event()
|
||||||
self._stop_lock = threading.RLock()
|
self._stop_lock = threading.RLock()
|
||||||
|
@ -86,73 +82,109 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
if hasattr(vlc.EventType, evt)
|
if hasattr(vlc.EventType, evt)
|
||||||
]
|
]
|
||||||
|
|
||||||
def _init_vlc(self, resource):
|
def _init_vlc(self, resource: MediaResource, cache_streams: bool):
|
||||||
import vlc
|
|
||||||
|
|
||||||
if self._instance:
|
if self._instance:
|
||||||
self.logger.info('Another instance is running, waiting for it to terminate')
|
self.logger.info('Another instance is running, waiting for it to terminate')
|
||||||
self._on_stop_event.wait()
|
self._on_stop_event.wait()
|
||||||
|
|
||||||
self._reset_state()
|
|
||||||
|
|
||||||
for k, v in self._env.items():
|
for k, v in self._env.items():
|
||||||
os.environ[k] = v
|
os.environ[k] = v
|
||||||
|
|
||||||
self._monitor_thread = threading.Thread(target=self._player_monitor)
|
self._monitor_thread = threading.Thread(target=self._player_monitor)
|
||||||
self._monitor_thread.start()
|
self._monitor_thread.start()
|
||||||
self._instance = vlc.Instance(*self._args)
|
self._set_media(resource, cache_streams=cache_streams)
|
||||||
assert self._instance, 'Could not create a VLC instance'
|
assert self._player, 'Could not create a VLC player instance'
|
||||||
self._player = self._instance.media_player_new(resource)
|
|
||||||
|
|
||||||
for evt in self._watched_event_types():
|
for evt in self._watched_event_types():
|
||||||
self._player.event_manager().event_attach(
|
self._player.event_manager().event_attach(
|
||||||
eventtype=evt, callback=self._event_callback()
|
eventtype=evt, callback=self._event_callback()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _set_media(
|
||||||
|
self, resource: MediaResource, *_, cache_streams: bool = False, **__
|
||||||
|
):
|
||||||
|
import vlc
|
||||||
|
|
||||||
|
if not self._instance:
|
||||||
|
self._instance = vlc.Instance(*self._args)
|
||||||
|
|
||||||
|
assert self._instance, 'Could not create a VLC instance'
|
||||||
|
if not self._player:
|
||||||
|
self._player = self._instance.media_player_new()
|
||||||
|
|
||||||
|
fd = resource.fd or resource.open(cache_streams=cache_streams)
|
||||||
|
|
||||||
|
if not cache_streams and fd is not None:
|
||||||
|
self._player.set_media(self._instance.media_new_fd(fd.fileno()))
|
||||||
|
else:
|
||||||
|
self._player.set_media(self._instance.media_new(resource.resource))
|
||||||
|
|
||||||
def _player_monitor(self):
|
def _player_monitor(self):
|
||||||
self._on_stop_event.wait()
|
self._on_stop_event.wait()
|
||||||
self.logger.info('VLC stream terminated')
|
self.logger.info('VLC stream terminated')
|
||||||
self._reset_state()
|
self.quit()
|
||||||
|
|
||||||
def _reset_state(self):
|
def _reset_state(self):
|
||||||
with self._stop_lock:
|
self._latest_seek = None
|
||||||
self._latest_seek = None
|
self._on_stop_event.clear()
|
||||||
self._title = None
|
|
||||||
self._filename = None
|
|
||||||
self._on_stop_event.clear()
|
|
||||||
|
|
||||||
if self._player:
|
if self._latest_resource:
|
||||||
self.logger.info('Releasing VLC player resource')
|
self.logger.debug('Closing latest resource')
|
||||||
self._player.release()
|
self._latest_resource.close()
|
||||||
self._player = None
|
self._latest_resource = None
|
||||||
|
|
||||||
if self._instance:
|
def _close_player(self):
|
||||||
self.logger.info('Releasing VLC instance resource')
|
if self._player:
|
||||||
self._instance.release()
|
self.logger.info('Releasing VLC player resource')
|
||||||
self._instance = None
|
self._player.stop()
|
||||||
|
|
||||||
|
if self._player.get_media():
|
||||||
|
self.logger.debug('Releasing VLC media resource')
|
||||||
|
self._player.get_media().release()
|
||||||
|
|
||||||
|
self.logger.debug('Releasing VLC player instance')
|
||||||
|
self._player.release()
|
||||||
|
self._player = None
|
||||||
|
|
||||||
|
if self._instance:
|
||||||
|
self.logger.info('Releasing VLC instance resource')
|
||||||
|
self._instance.release()
|
||||||
|
self._instance = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _post_event(evt_type, **evt):
|
def _post_event(evt_type, **evt):
|
||||||
bus = get_bus()
|
bus = get_bus()
|
||||||
bus.post(evt_type(player='local', plugin='media.vlc', **evt))
|
bus.post(evt_type(player='local', plugin='media.vlc', **evt))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _title(self) -> Optional[str]:
|
||||||
|
if not (self._player and self._player.get_media() and self._latest_resource):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return (
|
||||||
|
self._player.get_title()
|
||||||
|
or self._latest_resource.title
|
||||||
|
or self._latest_resource.filename
|
||||||
|
or self._player.get_media().get_mrl()
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
|
||||||
def _event_callback(self):
|
def _event_callback(self):
|
||||||
def callback(event):
|
def callback(event):
|
||||||
from vlc import EventType
|
from vlc import EventType
|
||||||
|
|
||||||
self.logger.debug('Received vlc event: %s', event)
|
self.logger.debug('Received VLC event: %s', event.type)
|
||||||
if event.type == EventType.MediaPlayerPlaying: # type: ignore
|
if event.type == EventType.MediaPlayerPlaying: # type: ignore
|
||||||
self._post_event(MediaPlayEvent, resource=self._get_current_resource())
|
self._post_event(MediaPlayEvent, resource=self._get_current_resource())
|
||||||
elif event.type == EventType.MediaPlayerPaused: # type: ignore
|
elif event.type == EventType.MediaPlayerPaused: # type: ignore
|
||||||
self._post_event(MediaPauseEvent)
|
self._post_event(MediaPauseEvent)
|
||||||
elif (
|
elif event.type in (
|
||||||
event.type == EventType.MediaPlayerStopped # type: ignore
|
EventType.MediaPlayerStopped, # type: ignore
|
||||||
or event.type == EventType.MediaPlayerEndReached # type: ignore
|
EventType.MediaPlayerEndReached, # type: ignore
|
||||||
):
|
):
|
||||||
self._on_stop_event.set()
|
self._on_stop_event.set()
|
||||||
self._post_event(MediaStopEvent)
|
self._post_event(MediaStopEvent)
|
||||||
for cbk in self._on_stop_callbacks:
|
self._reset_state()
|
||||||
cbk()
|
|
||||||
elif self._player and (
|
elif self._player and (
|
||||||
event.type
|
event.type
|
||||||
in (
|
in (
|
||||||
|
@ -160,7 +192,6 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
EventType.MediaPlayerMediaChanged, # type: ignore
|
EventType.MediaPlayerMediaChanged, # type: ignore
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
self._title = self._player.get_title() or self._filename
|
|
||||||
if event.type == EventType.MediaPlayerMediaChanged: # type: ignore
|
if event.type == EventType.MediaPlayerMediaChanged: # type: ignore
|
||||||
self._post_event(NewPlayingMediaEvent, resource=self._title)
|
self._post_event(NewPlayingMediaEvent, resource=self._title)
|
||||||
elif event.type == EventType.MediaPlayerLengthChanged: # type: ignore
|
elif event.type == EventType.MediaPlayerLengthChanged: # type: ignore
|
||||||
|
@ -180,6 +211,9 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
self._post_event(MediaMuteChangedEvent, mute=True)
|
self._post_event(MediaMuteChangedEvent, mute=True)
|
||||||
elif event.type == EventType.MediaPlayerUnmuted: # type: ignore
|
elif event.type == EventType.MediaPlayerUnmuted: # type: ignore
|
||||||
self._post_event(MediaMuteChangedEvent, mute=False)
|
self._post_event(MediaMuteChangedEvent, mute=False)
|
||||||
|
elif event.type == EventType.MediaPlayerEncounteredError: # type: ignore
|
||||||
|
self.logger.error('VLC media player encountered an error')
|
||||||
|
self._reset_state()
|
||||||
|
|
||||||
return callback
|
return callback
|
||||||
|
|
||||||
|
@ -190,6 +224,9 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
subtitles: Optional[str] = None,
|
subtitles: Optional[str] = None,
|
||||||
fullscreen: Optional[bool] = None,
|
fullscreen: Optional[bool] = None,
|
||||||
volume: Optional[int] = None,
|
volume: Optional[int] = None,
|
||||||
|
cache_streams: Optional[bool] = None,
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
|
**_,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Play a resource.
|
Play a resource.
|
||||||
|
@ -201,15 +238,22 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
`fullscreen` configured value or False)
|
`fullscreen` configured value or False)
|
||||||
:param volume: Set to explicitly set the playback volume (default:
|
:param volume: Set to explicitly set the playback volume (default:
|
||||||
`volume` configured value or 100)
|
`volume` configured value or 100)
|
||||||
|
:param cache_streams: Overrides the ``cache_streams`` configuration
|
||||||
|
value.
|
||||||
|
:param metadata: Optional metadata to pass to the resource.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not resource:
|
if not resource:
|
||||||
return self.pause()
|
return self.pause()
|
||||||
|
|
||||||
self._post_event(MediaPlayRequestEvent, resource=resource)
|
self._post_event(MediaPlayRequestEvent, resource=resource)
|
||||||
resource = self._get_resource(resource)
|
cache_streams = (
|
||||||
self._filename = resource
|
cache_streams if cache_streams is not None else self.cache_streams
|
||||||
self._init_vlc(resource)
|
)
|
||||||
|
media = self._get_resource(resource, metadata=metadata)
|
||||||
|
self._latest_resource = media
|
||||||
|
self.quit()
|
||||||
|
self._init_vlc(media, cache_streams=cache_streams)
|
||||||
if subtitles and self._player:
|
if subtitles and self._player:
|
||||||
if subtitles.startswith('file://'):
|
if subtitles.startswith('file://'):
|
||||||
subtitles = subtitles[len('file://') :]
|
subtitles = subtitles[len('file://') :]
|
||||||
|
@ -242,13 +286,17 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
"""Quit the player (same as `stop`)"""
|
"""Quit the player (same as `stop`)"""
|
||||||
with self._stop_lock:
|
with self._stop_lock:
|
||||||
if not self._player:
|
if not self._player:
|
||||||
self.logger.warning('No vlc instance is running')
|
self.logger.debug('No vlc instance is running')
|
||||||
return self.status()
|
return self.status()
|
||||||
|
|
||||||
self._player.stop()
|
|
||||||
self._on_stop_event.wait(timeout=5)
|
|
||||||
self._reset_state()
|
self._reset_state()
|
||||||
return self.status()
|
self._close_player()
|
||||||
|
self._on_stop_event.wait(timeout=5)
|
||||||
|
|
||||||
|
for cbk in self._on_stop_callbacks:
|
||||||
|
cbk()
|
||||||
|
|
||||||
|
return self.status()
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def stop(self, *_, **__):
|
def stop(self, *_, **__):
|
||||||
|
@ -385,7 +433,10 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
"""
|
"""
|
||||||
if not self._player:
|
if not self._player:
|
||||||
return self.play(resource, **args)
|
return self.play(resource, **args)
|
||||||
self._player.set_media(resource)
|
|
||||||
|
media = self._get_resource(resource)
|
||||||
|
self._reset_state()
|
||||||
|
self._set_media(media)
|
||||||
return self.status()
|
return self.status()
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -419,10 +470,10 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
import vlc
|
import vlc
|
||||||
|
|
||||||
with self._stop_lock:
|
with self._stop_lock:
|
||||||
if not self._player:
|
if not (self._player and self._latest_resource):
|
||||||
return {'state': PlayerState.STOP.value}
|
return {'state': PlayerState.STOP.value}
|
||||||
|
|
||||||
status = {}
|
status = self._latest_resource.to_dict()
|
||||||
vlc_state = self._player.get_state()
|
vlc_state = self._player.get_state()
|
||||||
|
|
||||||
if vlc_state == vlc.State.Playing: # type: ignore
|
if vlc_state == vlc.State.Playing: # type: ignore
|
||||||
|
@ -432,12 +483,6 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
else:
|
else:
|
||||||
status['state'] = PlayerState.STOP.value
|
status['state'] = PlayerState.STOP.value
|
||||||
|
|
||||||
status['url'] = (
|
|
||||||
urllib.parse.unquote(self._player.get_media().get_mrl())
|
|
||||||
if self._player.get_media()
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
status['position'] = (
|
status['position'] = (
|
||||||
float(self._player.get_time() / 1000)
|
float(self._player.get_time() / 1000)
|
||||||
if self._player.get_time() is not None
|
if self._player.get_time() is not None
|
||||||
|
@ -445,10 +490,13 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
)
|
)
|
||||||
|
|
||||||
media = self._player.get_media()
|
media = self._player.get_media()
|
||||||
status['duration'] = (
|
status['duration'] = status.get(
|
||||||
media.get_duration() / 1000
|
'duration',
|
||||||
if media and media.get_duration() is not None
|
(
|
||||||
else None
|
media.get_duration() / 1000
|
||||||
|
if media and media.get_duration() is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
status['seekable'] = status['duration'] is not None
|
status['seekable'] = status['duration'] is not None
|
||||||
|
@ -457,23 +505,10 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
status['path'] = status['url']
|
status['path'] = status['url']
|
||||||
status['pause'] = status['state'] == PlayerState.PAUSE.value
|
status['pause'] = status['state'] == PlayerState.PAUSE.value
|
||||||
status['percent_pos'] = self._player.get_position() * 100
|
status['percent_pos'] = self._player.get_position() * 100
|
||||||
status['filename'] = self._filename
|
status['filename'] = self._latest_resource.filename
|
||||||
status['title'] = self._title
|
status['title'] = self._title
|
||||||
status['volume'] = self._player.audio_get_volume()
|
status['volume'] = self._player.audio_get_volume()
|
||||||
status['volume_max'] = 100
|
status['volume_max'] = 100
|
||||||
|
|
||||||
if (
|
|
||||||
status['state'] in (PlayerState.PLAY.value, PlayerState.PAUSE.value)
|
|
||||||
and self._latest_resource
|
|
||||||
):
|
|
||||||
status.update(
|
|
||||||
{
|
|
||||||
k: v
|
|
||||||
for k, v in asdict(self._latest_resource).items()
|
|
||||||
if v is not None
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
def on_stop(self, callback):
|
def on_stop(self, callback):
|
||||||
|
@ -482,6 +517,10 @@ class MediaVlcPlugin(MediaPlugin):
|
||||||
def _get_current_resource(self):
|
def _get_current_resource(self):
|
||||||
if not self._player or not self._player.get_media():
|
if not self._player or not self._player.get_media():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if self._latest_resource:
|
||||||
|
return self._latest_resource.url
|
||||||
|
|
||||||
return self._player.get_media().get_mrl()
|
return self._player.get_media().get_mrl()
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue