[#422] Enabled support for yt-dlp mux+transcoding in media plugins

Reviewed-on: platypush/platypush#423
This commit is contained in:
Fabio Manganiello 2024-08-18 12:56:47 +02:00
parent ca5853cbab
commit 5080caa38e
49 changed files with 2220 additions and 1684 deletions

View file

@ -1,6 +0,0 @@
``media.omxplayer``
=====================================
.. automodule:: platypush.plugins.media.omxplayer
:members:

View file

@ -77,7 +77,6 @@ Plugins
platypush/plugins/media.kodi.rst
platypush/plugins/media.mplayer.rst
platypush/plugins/media.mpv.rst
platypush/plugins/media.omxplayer.rst
platypush/plugins/media.plex.rst
platypush/plugins/media.subtitles.rst
platypush/plugins/media.vlc.rst

View file

@ -25,10 +25,15 @@ def load_media_map() -> MediaMap:
logger().warning('Could not load media map: %s', e)
return {}
return {
media_id: MediaHandler.build(**media_info)
for media_id, media_info in media_map.items()
}
parsed_map = {}
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):

View file

@ -1,7 +1,6 @@
from abc import ABC, abstractmethod
import hashlib
import logging
import os
from typing import Generator, Optional
from platypush.message import JSONAble
@ -57,9 +56,6 @@ class MediaHandler(JSONAble, ABC):
logging.exception(e)
errors[hndl_class.__name__] = str(e)
if os.path.exists(source):
source = f'file://{source}'
raise AttributeError(
f'The source {source} has no handlers associated. Errors: {errors}'
)

View file

@ -15,6 +15,9 @@ class FileHandler(MediaHandler):
prefix_handlers = ['file://']
def __init__(self, source, *args, **kwargs):
if isinstance(source, str) and os.path.exists(source):
source = f'file://{source}'
super().__init__(source, *args, **kwargs)
self.path = os.path.abspath(

View file

@ -70,6 +70,7 @@ class RedisBus(Bus):
try:
data = msg.get('data', b'').decode('utf-8')
logger.debug('Received message on the Redis bus: %r', data)
parsed_msg = Message.build(data)
if parsed_msg and self.on_message:
self.on_message(parsed_msg)

View file

@ -29,10 +29,10 @@ class Pipeline:
self.bus = self.pipeline.get_bus()
self.bus.add_signal_watch()
self.bus.connect('message::eos', self.on_eos)
self.bus.connect('message::error', self.on_error)
self.bus.connect('message', self.on_message)
self.data_ready = threading.Event()
self.data = None
self._gst_state = Gst.State.NULL
def add(self, element_name: str, *args, **props):
el = Gst.ElementFactory.make(element_name, *args)
@ -94,6 +94,28 @@ class Pipeline:
assert self.source, 'No source initialized'
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):
sample = GstApp.AppSink.pull_sample(sink)
buffer = sample.get_buffer()
@ -106,6 +128,29 @@ class Pipeline:
self.logger.info('End of stream event received')
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):
self.logger.warning('GStreamer pipeline error: %s', msg.parse_error())
self.stop()

View file

@ -1,4 +1,3 @@
import copy
import json
import logging
import random
@ -7,11 +6,11 @@ import time
from dataclasses import dataclass, field
from datetime import date
from typing import Any
from typing import Any, Optional, Union
from platypush.config import Config
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')
@ -261,7 +260,7 @@ class Event(Message):
"""
Converts the event into a dictionary
"""
args = copy.deepcopy(self.args)
args = dict(deepcopy(self.args))
flatten(args)
return {
'type': 'event',
@ -277,7 +276,7 @@ class Event(Message):
Overrides the str() operator and converts
the message into a UTF-8 JSON string
"""
args = copy.deepcopy(self.args)
args = deepcopy(self.args)
flatten(args)
return json.dumps(self.as_dict(), cls=self.Encoder)
@ -297,6 +296,43 @@ class EventMatchResult:
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):
"""
Flatten a nested dictionary for string serialization.

View file

@ -1,10 +1,6 @@
import functools
import inspect
import json
import os
import pathlib
import queue
import re
import subprocess
import tempfile
import threading
from abc import ABC, abstractmethod
@ -13,6 +9,7 @@ from typing import (
Iterable,
List,
Optional,
Sequence,
Tuple,
Type,
Union,
@ -24,16 +21,18 @@ from platypush.config import Config
from platypush.context import get_plugin, get_backend
from platypush.message.event.media import MediaEvent
from platypush.plugins import RunnablePlugin, action
from platypush.utils import get_default_downloads_dir, get_plugin_name_by_class
from ._download import (
DownloadState,
DownloadThread,
FileDownloadThread,
YouTubeDownloadThread,
from platypush.utils import (
get_default_downloads_dir,
get_mime_type,
get_plugin_name_by_class,
)
from ._constants import audio_extensions, video_extensions
from ._model import DownloadState, PlayerState
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):
@ -51,97 +50,14 @@ class MediaPlugin(RunnablePlugin, ABC):
'This method must be implemented in a derived class'
)
# Supported audio extensions
audio_extensions = {
'3gp',
'aa',
'aac',
'aax',
'act',
'aiff',
'amr',
'ape',
'au',
'awb',
'dct',
'dss',
'dvf',
'flac',
'gsm',
'iklax',
'ivs',
'm4a',
'm4b',
'm4p',
'mmf',
'mp3',
'mpc',
'msv',
'nmf',
'nsf',
'ogg,',
'opus',
'ra,',
'raw',
'sln',
'tta',
'vox',
'wav',
'wma',
'wv',
'webm',
'8svx',
}
# 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',
}
audio_extensions = audio_extensions
video_extensions = video_extensions
supported_media_plugins = [
'media.vlc',
'media.mpv',
'media.mplayer',
'media.omxplayer',
'media.mpv',
'media.vlc',
'media.chromecast',
'media.gstreamer',
]
@ -156,9 +72,13 @@ class MediaPlugin(RunnablePlugin, ABC):
env: Optional[Dict[str, str]] = None,
volume: Optional[Union[float, int]] = None,
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',
merge_output_format: str = 'mp4',
cache_dir: Optional[str] = None,
cache_streams: bool = False,
ytdl_args: Optional[Sequence[str]] = None,
**kwargs,
):
"""
@ -187,6 +107,9 @@ class MediaPlugin(RunnablePlugin, ABC):
``bestvideo[height<=?1080][ext=mp4]+bestaudio`` - select the best
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
extract information from YouTube videos and other media platforms.
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,
this can be used to specify the format of the output container -
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)
if media_dirs is None:
media_dirs = []
player = None
player_config = {}
self._download_threads: Dict[Tuple[str, str], DownloadThread] = {}
@ -230,6 +170,7 @@ class MediaPlugin(RunnablePlugin, ABC):
self.registered_actions.add(act)
self._env = env or {}
self.cache_streams = cache_streams
self.media_dirs = set(
filter(
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.media_dirs.add(self.download_dir)
self.volume = volume
@ -253,62 +205,31 @@ class MediaPlugin(RunnablePlugin, ABC):
self._youtube_proc = None
self.torrent_plugin = torrent_plugin
self.youtube_format = youtube_format
self.youtube_audio_format = youtube_audio_format
self.merge_output_format = merge_output_format
self.ytdl_args = ytdl_args or []
self._latest_resource: Optional[MediaResource] = None
@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(event.args['files'])
self._parsers: Dict[Type[MediaResourceParser], MediaResourceParser] = {
parser: parser(self) for parser in parsers
}
return handler
self._downloaders: Dict[
Type[MediaResourceDownloader], MediaResourceDownloader
] = {downloader: downloader(self) for downloader in downloaders}
def get_extractors(self):
try:
from yt_dlp.extractor import _extractors # type: ignore
except ImportError:
self.logger.debug('yt_dlp not installed')
return
self._searchers: Dict[Type[MediaSearcher], MediaSearcher] = {
searcher: searcher(dirs=self.media_dirs, media_plugin=self)
for searcher in searchers
}
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
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):
def _get_resource(
self,
resource: str,
metadata: Optional[dict] = None,
only_audio: bool = False,
**_,
):
"""
:param resource: Resource to play/parse. Supported types:
@ -319,72 +240,19 @@ class MediaPlugin(RunnablePlugin, ABC):
"""
if resource.startswith('file://'):
path = resource[len('file://') :]
assert os.path.isfile(path), f'File {path} not found'
self._latest_resource = MediaResource(
resource=resource,
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'
for parser in self._parsers.values():
media_resource = parser.parse(resource, only_audio=only_audio)
if media_resource:
for k, v in (metadata or {}).items():
setattr(media_resource, k, v)
evt_queue = queue.Queue()
torrents.download(
resource,
download_dir=self.download_dir,
_async=True,
is_media=True,
event_hndl=self._torrent_event_handler(evt_queue),
)
return media_resource
resources = [f for f in evt_queue.get()] # noqa: C416,R1721
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)
raise AssertionError(f'Unknown media resource: {resource}')
@action
@abstractmethod
def play(self, resource, **kwargs):
def play(self, resource: str, **kwargs):
raise self._NOT_IMPLEMENTED_ERR
@action
@ -567,43 +435,45 @@ class MediaPlugin(RunnablePlugin, ABC):
return thread
def _get_search_handler_by_type(self, search_type: str):
if search_type == 'file':
from .search import LocalMediaSearcher
searcher = next(
iter(filter(lambda s: s.supports(search_type), self._searchers.values())),
None,
)
return LocalMediaSearcher(self.media_dirs, media_plugin=self)
if search_type == 'torrent':
from .search import TorrentMediaSearcher
if not searcher:
self.logger.warning('Unsupported search type: %s', search_type)
return None
return TorrentMediaSearcher(media_plugin=self)
if search_type == 'youtube':
from .search import YoutubeMediaSearcher
return YoutubeMediaSearcher(media_plugin=self)
if search_type == 'plex':
from .search import PlexMediaSearcher
return PlexMediaSearcher(media_plugin=self)
if search_type == 'jellyfin':
from .search import JellyfinMediaSearcher
return JellyfinMediaSearcher(media_plugin=self)
self.logger.warning('Unsupported search type: %s', search_type)
return None
return searcher
@classmethod
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
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):
if self._is_youtube_resource(resource):
return self.get_youtube_info(resource)
mime_type = get_mime_type(filename)
return bool(mime_type and mime_type.startswith('audio/'))
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
def start_streaming(
@ -660,109 +530,14 @@ class MediaPlugin(RunnablePlugin, ABC):
assert response.ok, response.text or response.reason
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
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
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]
)
],
)
return {'url': resource}
@action
def download(
@ -774,6 +549,7 @@ class MediaPlugin(RunnablePlugin, ABC):
sync: bool = False,
only_audio: bool = False,
youtube_format: Optional[str] = None,
youtube_audio_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
(only supported for yt-dlp-compatible URLs for now).
: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
``merge_output_format`` setting.
:return: The absolute path to the downloaded file.
"""
path = self._get_download_path(
url, directory=directory, filename=filename, youtube_format=youtube_format
)
dl_thread = None
resource = self._get_resource(url, only_audio=only_audio)
if self._is_youtube_resource(url):
dl_thread = self._download_youtube_url(
url, path, youtube_format=youtube_format, only_audio=only_audio
)
if filename:
path = os.path.expanduser(filename)
elif resource.filename:
path = resource.filename
else:
if only_audio:
self.logger.warning(
'Only audio download is not supported for non-YouTube URLs'
path = os.path.basename(resource.url)
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:
dl_thread.join()
@ -934,90 +731,10 @@ class MediaPlugin(RunnablePlugin, ABC):
assert threads, f'No matching downloads found for [url={url}, path={path}]'
return threads
def _get_download_path(
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):
def on_download_start(self, thread: DownloadThread):
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:
self.logger.warning(
'A download of %s to %s is already in progress', thread.url, thread.path
@ -1026,7 +743,7 @@ class MediaPlugin(RunnablePlugin, ABC):
thread.start()
def _post_event(self, event_type: Type[MediaEvent], **kwargs):
def post_event(self, event_type: Type[MediaEvent], **kwargs):
evt = event_type(
player=get_plugin_name_by_class(self.__class__), plugin=self, **kwargs
)
@ -1054,6 +771,14 @@ class MediaPlugin(RunnablePlugin, ABC):
f.write(content)
return f.name
@property
def supports_local_media(self) -> bool:
return True
@property
def supports_local_pipe(self) -> bool:
return True
def main(self):
self.wait_stop()
@ -1061,6 +786,8 @@ class MediaPlugin(RunnablePlugin, ABC):
__all__ = [
'DownloadState',
'MediaPlugin',
'MediaResource',
'MediaSearcher',
'PlayerState',
]

View 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:

View file

@ -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()

View 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:

View file

@ -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

View 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',
]

View 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:

View 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',
]

View 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:

View 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:

View 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()

View 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:

View 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:

View 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:

View 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:

View 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:

View 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:

View 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:

View 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:

View 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:

View 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:

View file

@ -1,9 +1,16 @@
from platypush.context import get_plugin
from platypush.plugins.media.search import MediaSearcher
from platypush.plugins.media._search import 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
:class:`platypush.plugins.media.jellyfin.MediaJellyfinPlugin`
@ -18,9 +25,11 @@ class JellyfinMediaSearcher(MediaSearcher):
if not media:
return []
self.logger.info('Searching Jellyfin for "{}"'.format(query))
self.logger.info('Searching Jellyfin for "%s"', query)
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

View file

@ -8,8 +8,7 @@ from sqlalchemy import create_engine
from sqlalchemy.sql.expression import func
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 (
Base,
@ -43,6 +42,9 @@ class LocalMediaSearcher(MediaSearcher):
self.db_file = os.path.join(db_dir, 'media.db')
self._db_engine = None
def supports(self, type: str) -> bool:
return type == 'file'
def _get_db_session(self):
if not self._db_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
SQLite index
"""
from platypush.plugins.media import MediaPlugin
if not session:
session = self._get_db_session()

View file

@ -3,11 +3,11 @@ import json
import logging
import multiprocessing
import os
import shutil
import subprocess
from concurrent.futures import ProcessPoolExecutor
from dateutil.parser import isoparse
import shutil
logger = logging.getLogger(__name__)
@ -58,6 +58,8 @@ def get_file_metadata(path: str):
'duration': ret.get('format', {}).get('duration'),
'width': video_stream.get('width'),
'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,
}

View 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:

View file

@ -1,14 +1,22 @@
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):
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(
self.media_plugin.torrent_plugin if self.media_plugin else 'torrent'
)
if not torrents:
raise RuntimeError('Torrent plugin not available/configured')
@ -20,5 +28,8 @@ class TorrentMediaSearcher(MediaSearcher):
if torrent.get('is_media')
]
def supports(self, type: str) -> bool:
return type == 'torrent'
# vim:sw=4:ts=4:et:

View file

@ -1,5 +1,5 @@
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
@ -18,5 +18,8 @@ class YoutubeMediaSearcher(MediaSearcher):
assert yt, 'YouTube plugin not available/configured'
return yt.search(query=query).output
def supports(self, type: str) -> bool:
return type == 'youtube'
# vim:sw=4:ts=4:et:

View file

@ -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'

View file

@ -1,4 +1,5 @@
from typing import Optional
from typing import Dict, Optional, Sequence
from uuid import UUID
from pychromecast import (
CastBrowser,
@ -10,9 +11,14 @@ from pychromecast import (
from platypush.backend.http.app.utils import get_remote_base_url
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.message.event.media import MediaPlayRequestEvent
from platypush.message.event.media import (
MediaEvent,
MediaPlayRequestEvent,
MediaStopEvent,
)
from ._listener import MediaListener
from ._subtitles import SubtitlesAsyncHandler
@ -29,22 +35,49 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
STREAM_TYPE_LIVE = "LIVE"
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 poll_interval: How often the plugin should poll for new/removed
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.chromecast = chromecast
self._chromecasts_by_uuid = {}
self._chromecasts_by_name = {}
self._media_listeners = {}
self._latest_resources_by_device: Dict[UUID, MediaResource] = {}
self._zc = None
self._browser = None
self._use_ytdl = use_ytdl
@property
def zc(self):
@ -90,6 +123,9 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
else:
raise RuntimeError('Invalid Chromecast object')
resource = self._latest_resources_by_device.get(cc.uuid)
resource_dump = resource.to_dict() if resource else {}
return {
'type': cc.cast_type,
'name': cc.name,
@ -111,19 +147,25 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
'volume': round(100 * cc.status.volume_level, 2),
'muted': cc.status.volume_muted,
**convert_status(cc.media_controller.status),
**resource_dump,
}
if cc.status
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_name[
self._get_device_property(cast, 'friendly_name')
] = cast
def get_chromecast(self, chromecast=None):
def get_chromecast(self, chromecast=None) -> Chromecast:
if isinstance(chromecast, Chromecast):
return chromecast
@ -151,7 +193,9 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
subtitles_lang: str = 'en-US',
subtitles_mime: str = 'text/vtt',
subtitle_id: int = 1,
**__,
youtube_format: Optional[str] = None,
use_ytdl: Optional[bool] = None,
**kwargs,
):
"""
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_mime: Subtitles MIME type (default: text/vtt)
: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:
@ -179,10 +225,26 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
post_event(MediaPlayRequestEvent, resource=resource, device=chromecast)
cast = self.get_chromecast(chromecast)
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:
content_type = get_mime_type(resource)
content_type = get_mime_type(media.resource)
if not content_type:
raise RuntimeError(f'content_type required to process media {resource}')
@ -192,19 +254,13 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
resource = get_remote_base_url() + 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)
mc.play_media(
resource,
content_type,
title=self._latest_resource.title if self._latest_resource else title,
thumb=image_url,
title=title or media.title,
thumb=image_url or media.image,
current_time=current_time,
autoplay=autoplay,
stream_type=stream_type,
@ -629,6 +685,15 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
self._chromecasts_by_uuid[cc.uuid] = 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):
while not self.should_stop():
try:

View file

@ -1,8 +1,9 @@
import logging
import time
from typing import Optional
from typing import Optional, Type
from platypush.message.event.media import (
MediaEvent,
MediaPlayEvent,
MediaStopEvent,
MediaPauseEvent,
@ -42,7 +43,10 @@ class MediaListener:
self._post_event(MediaPlayEvent)
elif state == 'pause':
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)
if status.get('volume') != self.status.get('volume'):
@ -62,7 +66,7 @@ class MediaListener:
def load_media_failed(self, 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', {})
resource = status.get('url')
args = {

View file

@ -1,4 +1,3 @@
from dataclasses import asdict
import os
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.plugins import action
from platypush.plugins.media import MediaResource
from platypush.plugins.media.gstreamer.model import MediaPipeline
@ -21,20 +21,61 @@ class MediaGstreamerPlugin(MediaPlugin):
super().__init__(*args, **kwargs)
self.sink = sink
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)
if self.sink:
sink = pipeline.add_sink(self.sink, sync=False)
pipeline.link(pipeline.get_source(), sink)
self._player = pipeline
self._resource = resource
self._latest_resource = resource
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
def play(self, resource: Optional[str] = None, **_):
def play(self, resource: Optional[str] = None, **kwargs):
"""
Play a resource.
@ -44,36 +85,45 @@ class MediaGstreamerPlugin(MediaPlugin):
if not resource:
if self._player:
self._player.play()
return
return self._status()
resource = self._get_resource(resource)
path = os.path.abspath(os.path.expanduser(resource))
if os.path.exists(path):
resource = 'file://' + path
self._bus.post(
MediaPlayRequestEvent(
player='local', plugin='media.gstreamer', resource=resource
)
)
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(resource)
pipeline = self._allocate_pipeline(media)
pipeline.play()
if self.volume:
pipeline.set_volume(self.volume / 100.0)
return self.status()
return self._status()
@action
def pause(self):
def pause(self, *_, **__):
"""Toggle the paused state"""
assert self._player, 'No instance is running'
self._player.pause()
return self.status()
return self._status()
@action
def quit(self):
def quit(self, *_, **__):
"""Stop and quit the player (alias for :meth:`.stop`)"""
self._stop_torrent()
assert self._player, 'No instance is running'
if self._latest_resource:
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}
@action
@ -84,14 +134,12 @@ class MediaGstreamerPlugin(MediaPlugin):
@action
def voldown(self, step=10.0):
"""Volume down by (default: 10)%"""
# noinspection PyUnresolvedReferences
return self.set_volume(self.get_volume().output - step)
return self.set_volume(self._get_volume() - step)
@action
def volup(self, step=10.0):
"""Volume up by (default: 10)%"""
# noinspection PyUnresolvedReferences
return self.set_volume(self.get_volume().output + step)
return self.set_volume(self._get_volume() + step)
@action
def get_volume(self) -> float:
@ -111,11 +159,10 @@ class MediaGstreamerPlugin(MediaPlugin):
:param volume: Volume value between 0 and 100.
"""
assert self._player, 'Player not running'
# noinspection PyTypeChecker
volume = max(0, min(1, volume / 100.0))
self._player.set_volume(volume)
MediaPipeline.post_event(MediaVolumeChangedEvent, volume=volume * 100)
return self.status()
self._player.post_event(MediaVolumeChangedEvent, volume=volume * 100)
return self._status()
@action
def seek(self, position: float) -> dict:
@ -126,7 +173,8 @@ class MediaGstreamerPlugin(MediaPlugin):
"""
assert self._player, 'No instance is running'
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
def back(self, offset=60.0):
@ -174,60 +222,25 @@ class MediaGstreamerPlugin(MediaPlugin):
"""
assert self._player, 'No instance is running'
self._player.seek(position)
return self.status()
return self._status()
@action
def status(self) -> dict:
"""
Get the current player state.
"""
if not self._player:
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
return self._status()
@staticmethod
def _gst_to_player_state(state) -> PlayerState:
# noinspection PyUnresolvedReferences,PyPackageRequirements
from gi.repository import Gst
from gi.repository import Gst # type: ignore
if state == Gst.State.READY:
return PlayerState.STOP
if state == Gst.State.PAUSED:
return PlayerState.PAUSE
if state == Gst.State.PLAYING:
return PlayerState.PLAY
return PlayerState.IDLE
return PlayerState.STOP
def toggle_subtitles(self, *_, **__):
raise NotImplementedError

View file

@ -2,41 +2,64 @@ from typing import Type
from platypush.common.gstreamer import Pipeline
from platypush.context import get_bus
from platypush.message.event.media import MediaEvent, MediaPlayEvent, MediaPauseEvent, MediaStopEvent, \
NewPlayingMediaEvent, MediaMuteChangedEvent, MediaSeekEvent
from platypush.message.event.media import (
MediaEvent,
MediaPlayEvent,
MediaPauseEvent,
MediaStopEvent,
NewPlayingMediaEvent,
MediaMuteChangedEvent,
MediaSeekEvent,
)
from platypush.plugins.media import MediaResource
class MediaPipeline(Pipeline):
def __init__(self, resource: str):
def __init__(self, resource: MediaResource):
super().__init__()
self.resource = resource
self.add_source('playbin', uri=resource)
@staticmethod
def post_event(evt_class: Type[MediaEvent], **kwargs):
self.resource = resource
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['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)
get_bus().post(evt)
def play(self):
# noinspection PyUnresolvedReferences,PyPackageRequirements
from gi.repository import Gst
is_first_play = self.get_state() == Gst.State.NULL
super().play()
if is_first_play:
def on_play(self):
super().on_play()
if self._first_play:
self.post_event(NewPlayingMediaEvent, resource=self.resource)
self.post_event(MediaPlayEvent, resource=self.resource)
self._first_play = False
def pause(self):
# noinspection PyUnresolvedReferences,PyPackageRequirements
self.post_event(MediaPlayEvent)
def on_pause(self):
super().on_pause()
self.post_event(MediaPauseEvent)
def play(self):
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):
super().stop()
self._first_play = True
self.post_event(MediaStopEvent)
def mute(self):
@ -48,7 +71,20 @@ class MediaPipeline(Pipeline):
self.post_event(MediaMuteChangedEvent, mute=self.is_muted())
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())

View file

@ -1,6 +1,8 @@
import json
import threading
import time
from typing import Optional
from urllib.parse import urlparse
from platypush.context import get_bus
from platypush.plugins import action
@ -19,42 +21,36 @@ class MediaKodiPlugin(MediaPlugin):
Plugin to interact with a Kodi media player instance
"""
# noinspection HttpUrlsUsage
def __init__(
self,
host,
http_port=8080,
websocket_port=9090,
username=None,
password=None,
rpc_url: str = 'http://localhost:8080/jsonrpc',
websocket_port: int = 9090,
username: Optional[str] = None,
password: Optional[str] = None,
**kwargs,
):
"""
:param host: Kodi host name or IP
:type host: str
:param http_port: Kodi JSON RPC web port. Remember to enable "Allow remote control via HTTP"
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 rpc_url: Base URL for the Kodi JSON RPC API (default:
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
settings.
:param websocket_port: Kodi JSON RPC websocket port, used to receive player events
:type websocket_port: int
:param username: Kodi username (optional)
:type username: str
:param password: Kodi password (optional)
:type password: str
"""
super().__init__(**kwargs)
self.host = host
self.http_port = http_port
self.url = rpc_url
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.url = 'http://{}:{}/jsonrpc'.format(host, http_port)
self.websocket_url = 'ws://{}:{}/jsonrpc'.format(host, websocket_port)
self.websocket_url = f'ws://{self.host}:{websocket_port}/jsonrpc'
self.username = username
self.password = password
self._ws = None
@ -113,7 +109,7 @@ class MediaKodiPlugin(MediaPlugin):
def _on_ws_msg(self):
def hndl(*args):
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)
method = msg.get('method')
@ -138,6 +134,7 @@ class MediaKodiPlugin(MediaPlugin):
elif method == 'Player.OnStop':
player = msg.get('params', {}).get('data', {}).get('player', {})
self._post_event(MediaStopEvent, player_id=player.get('playerid'))
self._clear_resource()
elif method == 'Player.OnSeek':
player = msg.get('params', {}).get('data', {}).get('player', {})
position = self._time_obj_to_pos(player.get('seekoffset'))
@ -153,7 +150,7 @@ class MediaKodiPlugin(MediaPlugin):
def _on_ws_error(self):
def hndl(*args):
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
@ -171,25 +168,28 @@ class MediaKodiPlugin(MediaPlugin):
status['result'] = result.get('result')
return status, result.get('error')
def _clear_resource(self):
if self._latest_resource:
self._latest_resource.close()
self._latest_resource = None
@action
def play(self, resource, *args, **kwargs):
def play(self, resource: str, **kwargs):
"""
Open and play the specified file or URL
:param resource: URL or path to the media to be played
"""
if resource.startswith('file://'):
resource = resource[7:]
result = self._get_kodi().Player.Open(item={'file': resource})
media = self._latest_resource = self._get_resource(resource, **kwargs)
media.open(**kwargs)
result = self._get_kodi().Player.Open(item={'file': media.resource})
if self.volume:
self.set_volume(volume=int(self.volume))
return self._build_result(result)
@action
def pause(self, player_id=None, *args, **kwargs):
def pause(self, player_id=None, **_):
"""
Play/pause the current media
"""
@ -212,7 +212,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def get_movies(self, *args, **kwargs):
def get_movies(self, **_):
"""
Get the list of movies on the Kodi server
"""
@ -221,7 +221,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def stop(self, player_id=None, *args, **kwargs):
def stop(self, player_id=None, **_):
"""
Stop the current media
"""
@ -232,10 +232,11 @@ class MediaKodiPlugin(MediaPlugin):
return None, 'No active players found'
result = self._get_kodi().Player.Stop(playerid=player_id)
self._clear_resource()
return self._build_result(result)
@action
def notify(self, title, message, *args, **kwargs):
def notify(self, title, message, **_):
"""
Send a notification to the Kodi UI
"""
@ -244,7 +245,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def left(self, *args, **kwargs):
def left(self, **_):
"""
Simulate a left input event
"""
@ -253,7 +254,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def right(self, *args, **kwargs):
def right(self, **_):
"""
Simulate a right input event
"""
@ -262,7 +263,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def up(self, *args, **kwargs):
def up(self, **_):
"""
Simulate an up input event
"""
@ -271,7 +272,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def down(self, *args, **kwargs):
def down(self, **_):
"""
Simulate a down input event
"""
@ -280,7 +281,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def back_btn(self, *args, **kwargs):
def back_btn(self, **_):
"""
Simulate a back input event
"""
@ -289,7 +290,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def select(self, *args, **kwargs):
def select(self, **_):
"""
Simulate a select input event
"""
@ -298,7 +299,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def send_text(self, text, *args, **kwargs):
def send_text(self, text, **_):
"""
Simulate a send_text input event
@ -310,13 +311,13 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def get_volume(self, *args, **kwargs):
def get_volume(self, **_):
result = self._get_kodi().Application.GetProperties(properties=['volume'])
return result.get('result'), result.get('error')
@action
def volup(self, step=10.0, *args, **kwargs):
def volup(self, step=10.0, **_):
"""Volume up (default: +10%)"""
volume = (
self._get_kodi()
@ -331,7 +332,7 @@ class MediaKodiPlugin(MediaPlugin):
return self._build_result(result)
@action
def voldown(self, step=10.0, *args, **kwargs):
def voldown(self, step=10.0, **_):
"""Volume down (default: -10%)"""
volume = (
self._get_kodi()
@ -346,7 +347,7 @@ class MediaKodiPlugin(MediaPlugin):
return self._build_result(result)
@action
def set_volume(self, volume, *args, **kwargs):
def set_volume(self, volume, **_):
"""
Set the application volume
@ -358,7 +359,7 @@ class MediaKodiPlugin(MediaPlugin):
return self._build_result(result)
@action
def mute(self, *args, **kwargs):
def mute(self, **_):
"""
Mute/unmute the application
"""
@ -374,7 +375,7 @@ class MediaKodiPlugin(MediaPlugin):
return self._build_result(result)
@action
def is_muted(self, *args, **kwargs):
def is_muted(self, **_):
"""
Return the muted status of the application
"""
@ -383,7 +384,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result')
@action
def scan_video_library(self, *args, **kwargs):
def scan_video_library(self, **_):
"""
Scan the video library
"""
@ -392,7 +393,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def scan_audio_library(self, *args, **kwargs):
def scan_audio_library(self, **_):
"""
Scan the audio library
"""
@ -401,7 +402,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def clean_video_library(self, *args, **kwargs):
def clean_video_library(self, **_):
"""
Clean the video library
"""
@ -410,7 +411,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def clean_audio_library(self, *args, **kwargs):
def clean_audio_library(self, **_):
"""
Clean the audio library
"""
@ -419,16 +420,17 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def quit(self, *args, **kwargs):
def quit(self, **_):
"""
Quit the application
"""
result = self._get_kodi().Application.Quit()
self._clear_resource()
return result.get('result'), result.get('error')
@action
def get_songs(self, *args, **kwargs):
def get_songs(self, **_):
"""
Get the list of songs in the audio library
"""
@ -437,7 +439,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def get_artists(self, *args, **kwargs):
def get_artists(self, **_):
"""
Get the list of artists in the audio library
"""
@ -446,7 +448,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def get_albums(self, *args, **kwargs):
def get_albums(self, **_):
"""
Get the list of albums in the audio library
"""
@ -455,7 +457,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def fullscreen(self, *args, **kwargs):
def fullscreen(self, **_):
"""
Set/unset fullscreen mode
"""
@ -471,7 +473,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def shuffle(self, player_id=None, shuffle=None, *args, **kwargs):
def shuffle(self, player_id=None, shuffle=None, **_):
"""
Set/unset shuffle mode
"""
@ -495,7 +497,7 @@ class MediaKodiPlugin(MediaPlugin):
return result.get('result'), result.get('error')
@action
def repeat(self, player_id=None, repeat=None, *args, **kwargs):
def repeat(self, player_id=None, repeat=None, **_):
"""
Set/unset repeat mode
"""
@ -543,7 +545,7 @@ class MediaKodiPlugin(MediaPlugin):
)
@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
@ -573,7 +575,7 @@ class MediaKodiPlugin(MediaPlugin):
return self.seek(*args, position=position, player_id=player_id, **kwargs)
@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
@ -594,11 +596,11 @@ class MediaKodiPlugin(MediaPlugin):
.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)
@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
@ -619,7 +621,7 @@ class MediaKodiPlugin(MediaPlugin):
.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)
@action
@ -715,20 +717,28 @@ class MediaKodiPlugin(MediaPlugin):
)
return ret
def toggle_subtitles(self, *args, **kwargs):
def toggle_subtitles(self, *_, **__):
raise NotImplementedError
def set_subtitles(self, filename, *args, **kwargs):
def set_subtitles(self, *_, **__):
raise NotImplementedError
def remove_subtitles(self, *args, **kwargs):
def remove_subtitles(self, *_, **__):
raise NotImplementedError
def is_playing(self, *args, **kwargs):
def is_playing(self, *_, **__):
raise NotImplementedError
def load(self, resource, *args, **kwargs):
def load(self, *_, **__):
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:

View file

@ -4,7 +4,7 @@ import re
import subprocess
import threading
import time
from dataclasses import asdict, dataclass
from dataclasses import dataclass
from multiprocessing import Process, Queue, RLock
from queue import Empty
from typing import Any, Collection, Dict, List, Optional
@ -395,7 +395,7 @@ class MediaMplayerPlugin(MediaPlugin):
resource: str,
subtitles: Optional[str] = None,
mplayer_args: Optional[List[str]] = None,
**_,
**kwargs,
):
"""
Play a resource.
@ -412,11 +412,11 @@ class MediaMplayerPlugin(MediaPlugin):
if subs:
mplayer_args = list(mplayer_args or []) + ['-sub', subs]
resource = self._get_resource(resource)
if resource.startswith('file://'):
resource = resource[7:]
media = self._latest_resource = self._get_resource(resource, **kwargs)
media.open(**kwargs)
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:
self.set_volume(volume=self.volume)
@ -435,6 +435,10 @@ class MediaMplayerPlugin(MediaPlugin):
def _cleanup(self):
with self._cleanup_lock:
if self._latest_resource:
self._latest_resource.close()
self._latest_resource = None
if self._player:
self._player.terminate()
self._player.wait()
@ -666,7 +670,7 @@ class MediaMplayerPlugin(MediaPlugin):
status.update(
{
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
}
)
@ -688,7 +692,7 @@ class MediaMplayerPlugin(MediaPlugin):
property,
prefix='pausing_keep_force',
wait_for_response=True,
*args,
*args, # noqa: B026
)
or {}
)
@ -757,7 +761,7 @@ class MediaMplayerPlugin(MediaPlugin):
value,
prefix='pausing_keep_force' if property != 'pause' else None,
wait_for_response=True,
*args,
*args, # noqa: B026
)
or {}
)
@ -799,7 +803,7 @@ class MediaMplayerPlugin(MediaPlugin):
value,
prefix='pausing_keep_force',
wait_for_response=True,
*args,
*args, # noqa: B026
)
or {}
)
@ -820,5 +824,9 @@ class MediaMplayerPlugin(MediaPlugin):
self.logger.debug('set_subtitles called with filename=%s', filename)
raise NotImplementedError
@property
def supports_local_pipe(self) -> bool:
return False
# vim:sw=4:ts=4:et:

View file

@ -1,10 +1,10 @@
import os
from dataclasses import asdict
from typing import Any, Dict, Optional, Type
from urllib.parse import quote
from platypush.plugins import action
from platypush.plugins.media import PlayerState, MediaPlugin
from platypush.plugins.media._resource import MediaResource, YoutubeMediaResource
from platypush.message.event.media import (
MediaEvent,
MediaPlayEvent,
@ -23,7 +23,6 @@ class MediaMpvPlugin(MediaPlugin):
"""
_default_mpv_args = {
'ytdl': True,
'start_event_thread': True,
}
@ -49,16 +48,42 @@ class MediaMpvPlugin(MediaPlugin):
self._player = None
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
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:
mpv_args.update(args)
mpv_args.pop('metadata', None)
for k, v in self._env.items():
os.environ[k] = v
self.logger.debug('Initializing mpv with args: %s', mpv_args)
self._player = mpv.MPV(**mpv_args)
self._player._event_callbacks += [self._event_callback()]
@ -159,6 +184,10 @@ class MediaMpvPlugin(MediaPlugin):
*_,
subtitles: Optional[str] = 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,
):
"""
@ -168,20 +197,31 @@ class MediaMpvPlugin(MediaPlugin):
:param subtitles: Path to optional subtitle file
:param args: Extra runtime arguments that will be passed to the
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)
if fullscreen is not None:
args['fs'] = fullscreen
self._init_mpv(args)
resource = self._get_resource(resource)
if resource.startswith('file://'):
resource = resource[7:]
media = self._latest_resource = self._get_resource(resource, metadata=metadata)
self._init_mpv(
args,
resource=media,
youtube_format=youtube_format,
youtube_audio_format=youtube_audio_format,
only_audio=only_audio,
)
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:
self.set_volume(volume=self.volume)
if subtitles:
@ -498,7 +538,7 @@ class MediaMpvPlugin(MediaPlugin):
status.update(
{
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
}
)
@ -516,11 +556,9 @@ class MediaMpvPlugin(MediaPlugin):
self._latest_state = self._state
return status
def _get_resource(self, resource):
if self._is_youtube_resource(resource):
return resource # mpv can handle YouTube streaming natively
return super()._get_resource(resource)
@property
def supports_local_pipe(self) -> bool:
return False
# vim:sw=4:ts=4:et:

View file

@ -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:

View file

@ -1,17 +0,0 @@
{
"manifest": {
"events": {},
"install": {
"pip": [
"omxplayer-wrapper",
"yt-dlp"
],
"apt": [
"omxplayer",
"yt-dlp"
]
},
"package": "platypush.plugins.media.omxplayer",
"type": "plugin"
}
}

View file

@ -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:

View file

@ -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:

View file

@ -1,11 +1,9 @@
from dataclasses import asdict
import os
import threading
import urllib.parse
from typing import Collection, Optional
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 (
MediaPlayEvent,
MediaPlayRequestEvent,
@ -30,7 +28,7 @@ class MediaVlcPlugin(MediaPlugin):
args: Optional[Collection[str]] = None,
fullscreen: bool = False,
volume: int = 100,
**kwargs
**kwargs,
):
"""
: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_volume = volume
self._on_stop_callbacks = []
self._title = None
self._filename = None
self._monitor_thread: Optional[threading.Thread] = None
self._on_stop_event = threading.Event()
self._stop_lock = threading.RLock()
@ -86,73 +82,109 @@ class MediaVlcPlugin(MediaPlugin):
if hasattr(vlc.EventType, evt)
]
def _init_vlc(self, resource):
import vlc
def _init_vlc(self, resource: MediaResource, cache_streams: bool):
if self._instance:
self.logger.info('Another instance is running, waiting for it to terminate')
self._on_stop_event.wait()
self._reset_state()
for k, v in self._env.items():
os.environ[k] = v
self._monitor_thread = threading.Thread(target=self._player_monitor)
self._monitor_thread.start()
self._instance = vlc.Instance(*self._args)
assert self._instance, 'Could not create a VLC instance'
self._player = self._instance.media_player_new(resource)
self._set_media(resource, cache_streams=cache_streams)
assert self._player, 'Could not create a VLC player instance'
for evt in self._watched_event_types():
self._player.event_manager().event_attach(
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):
self._on_stop_event.wait()
self.logger.info('VLC stream terminated')
self._reset_state()
self.quit()
def _reset_state(self):
with self._stop_lock:
self._latest_seek = None
self._title = None
self._filename = None
self._on_stop_event.clear()
self._latest_seek = None
self._on_stop_event.clear()
if self._player:
self.logger.info('Releasing VLC player resource')
self._player.release()
self._player = None
if self._latest_resource:
self.logger.debug('Closing latest resource')
self._latest_resource.close()
self._latest_resource = None
if self._instance:
self.logger.info('Releasing VLC instance resource')
self._instance.release()
self._instance = None
def _close_player(self):
if self._player:
self.logger.info('Releasing VLC player resource')
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
def _post_event(evt_type, **evt):
bus = get_bus()
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 callback(event):
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
self._post_event(MediaPlayEvent, resource=self._get_current_resource())
elif event.type == EventType.MediaPlayerPaused: # type: ignore
self._post_event(MediaPauseEvent)
elif (
event.type == EventType.MediaPlayerStopped # type: ignore
or event.type == EventType.MediaPlayerEndReached # type: ignore
elif event.type in (
EventType.MediaPlayerStopped, # type: ignore
EventType.MediaPlayerEndReached, # type: ignore
):
self._on_stop_event.set()
self._post_event(MediaStopEvent)
for cbk in self._on_stop_callbacks:
cbk()
self._reset_state()
elif self._player and (
event.type
in (
@ -160,7 +192,6 @@ class MediaVlcPlugin(MediaPlugin):
EventType.MediaPlayerMediaChanged, # type: ignore
)
):
self._title = self._player.get_title() or self._filename
if event.type == EventType.MediaPlayerMediaChanged: # type: ignore
self._post_event(NewPlayingMediaEvent, resource=self._title)
elif event.type == EventType.MediaPlayerLengthChanged: # type: ignore
@ -180,6 +211,9 @@ class MediaVlcPlugin(MediaPlugin):
self._post_event(MediaMuteChangedEvent, mute=True)
elif event.type == EventType.MediaPlayerUnmuted: # type: ignore
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
@ -190,6 +224,9 @@ class MediaVlcPlugin(MediaPlugin):
subtitles: Optional[str] = None,
fullscreen: Optional[bool] = None,
volume: Optional[int] = None,
cache_streams: Optional[bool] = None,
metadata: Optional[dict] = None,
**_,
):
"""
Play a resource.
@ -201,15 +238,22 @@ class MediaVlcPlugin(MediaPlugin):
`fullscreen` configured value or False)
:param volume: Set to explicitly set the playback volume (default:
`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:
return self.pause()
self._post_event(MediaPlayRequestEvent, resource=resource)
resource = self._get_resource(resource)
self._filename = resource
self._init_vlc(resource)
cache_streams = (
cache_streams if cache_streams is not None else self.cache_streams
)
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.startswith('file://'):
subtitles = subtitles[len('file://') :]
@ -242,13 +286,17 @@ class MediaVlcPlugin(MediaPlugin):
"""Quit the player (same as `stop`)"""
with self._stop_lock:
if not self._player:
self.logger.warning('No vlc instance is running')
self.logger.debug('No vlc instance is running')
return self.status()
self._player.stop()
self._on_stop_event.wait(timeout=5)
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
def stop(self, *_, **__):
@ -385,7 +433,10 @@ class MediaVlcPlugin(MediaPlugin):
"""
if not self._player:
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()
@action
@ -419,10 +470,10 @@ class MediaVlcPlugin(MediaPlugin):
import vlc
with self._stop_lock:
if not self._player:
if not (self._player and self._latest_resource):
return {'state': PlayerState.STOP.value}
status = {}
status = self._latest_resource.to_dict()
vlc_state = self._player.get_state()
if vlc_state == vlc.State.Playing: # type: ignore
@ -432,12 +483,6 @@ class MediaVlcPlugin(MediaPlugin):
else:
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'] = (
float(self._player.get_time() / 1000)
if self._player.get_time() is not None
@ -445,10 +490,13 @@ class MediaVlcPlugin(MediaPlugin):
)
media = self._player.get_media()
status['duration'] = (
media.get_duration() / 1000
if media and media.get_duration() is not None
else None
status['duration'] = status.get(
'duration',
(
media.get_duration() / 1000
if media and media.get_duration() is not None
else None
),
)
status['seekable'] = status['duration'] is not None
@ -457,23 +505,10 @@ class MediaVlcPlugin(MediaPlugin):
status['path'] = status['url']
status['pause'] = status['state'] == PlayerState.PAUSE.value
status['percent_pos'] = self._player.get_position() * 100
status['filename'] = self._filename
status['filename'] = self._latest_resource.filename
status['title'] = self._title
status['volume'] = self._player.audio_get_volume()
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
def on_stop(self, callback):
@ -482,6 +517,10 @@ class MediaVlcPlugin(MediaPlugin):
def _get_current_resource(self):
if not self._player or not self._player.get_media():
return None
if self._latest_resource:
return self._latest_resource.url
return self._player.get_media().get_mrl()