[#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.kodi.rst
platypush/plugins/media.mplayer.rst platypush/plugins/media.mplayer.rst
platypush/plugins/media.mpv.rst platypush/plugins/media.mpv.rst
platypush/plugins/media.omxplayer.rst
platypush/plugins/media.plex.rst platypush/plugins/media.plex.rst
platypush/plugins/media.subtitles.rst platypush/plugins/media.subtitles.rst
platypush/plugins/media.vlc.rst platypush/plugins/media.vlc.rst

View file

@ -25,10 +25,15 @@ def load_media_map() -> MediaMap:
logger().warning('Could not load media map: %s', e) logger().warning('Could not load media map: %s', e)
return {} return {}
return { parsed_map = {}
media_id: MediaHandler.build(**media_info) for media_id, media_info in media_map.items():
for media_id, media_info in media_map.items() try:
} parsed_map[media_id] = MediaHandler.build(**media_info)
except Exception as e:
logger().debug('Could not load media %s: %s', media_id, e)
continue
return parsed_map
def save_media_map(new_map: MediaMap): def save_media_map(new_map: MediaMap):

View file

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

View file

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

View file

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

View file

@ -29,10 +29,10 @@ class Pipeline:
self.bus = self.pipeline.get_bus() self.bus = self.pipeline.get_bus()
self.bus.add_signal_watch() self.bus.add_signal_watch()
self.bus.connect('message::eos', self.on_eos) self.bus.connect('message', self.on_message)
self.bus.connect('message::error', self.on_error)
self.data_ready = threading.Event() self.data_ready = threading.Event()
self.data = None self.data = None
self._gst_state = Gst.State.NULL
def add(self, element_name: str, *args, **props): def add(self, element_name: str, *args, **props):
el = Gst.ElementFactory.make(element_name, *args) el = Gst.ElementFactory.make(element_name, *args)
@ -94,6 +94,28 @@ class Pipeline:
assert self.source, 'No source initialized' assert self.source, 'No source initialized'
self.source.set_property('volume', volume) self.source.set_property('volume', volume)
def _msg_handler(self, message) -> bool:
from gi.repository import Gst # type: ignore[attr-defined]
if message.type == Gst.MessageType.EOS:
self.on_eos()
return True
if message.type == Gst.MessageType.ERROR:
err, debug = message.parse_error()
self.on_error(err, debug)
return True
if message.type == Gst.MessageType.STATE_CHANGED:
old_state, new_state, _ = message.parse_state_changed()[:3]
self.on_state_changed(old_state, new_state)
return True
return False # The message was not handled
def on_message(self, _, message, *__):
self._msg_handler(message)
def on_buffer(self, sink): def on_buffer(self, sink):
sample = GstApp.AppSink.pull_sample(sink) sample = GstApp.AppSink.pull_sample(sink)
buffer = sample.get_buffer() buffer = sample.get_buffer()
@ -106,6 +128,29 @@ class Pipeline:
self.logger.info('End of stream event received') self.logger.info('End of stream event received')
self.stop() self.stop()
def on_state_changed(self, old_state, new_state):
from gi.repository import Gst # type: ignore[attr-defined]
if (
old_state == new_state
or new_state == self._gst_state
or old_state != self._gst_state
):
return
self._gst_state = new_state
if new_state == Gst.State.PLAYING:
self.on_play()
elif new_state == Gst.State.PAUSED:
self.on_pause()
def on_play(self):
self.logger.debug('GStreamer playback started')
def on_pause(self):
self.logger.debug('GStreamer playback paused')
def on_error(self, _, msg): def on_error(self, _, msg):
self.logger.warning('GStreamer pipeline error: %s', msg.parse_error()) self.logger.warning('GStreamer pipeline error: %s', msg.parse_error())
self.stop() self.stop()

View file

@ -1,4 +1,3 @@
import copy
import json import json
import logging import logging
import random import random
@ -7,11 +6,11 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date from datetime import date
from typing import Any from typing import Any, Optional, Union
from platypush.config import Config from platypush.config import Config
from platypush.message import Message from platypush.message import Message
from platypush.utils import get_event_class_by_type from platypush.utils import get_event_class_by_type, get_plugin_name_by_class
logger = logging.getLogger('platypush') logger = logging.getLogger('platypush')
@ -261,7 +260,7 @@ class Event(Message):
""" """
Converts the event into a dictionary Converts the event into a dictionary
""" """
args = copy.deepcopy(self.args) args = dict(deepcopy(self.args))
flatten(args) flatten(args)
return { return {
'type': 'event', 'type': 'event',
@ -277,7 +276,7 @@ class Event(Message):
Overrides the str() operator and converts Overrides the str() operator and converts
the message into a UTF-8 JSON string the message into a UTF-8 JSON string
""" """
args = copy.deepcopy(self.args) args = deepcopy(self.args)
flatten(args) flatten(args)
return json.dumps(self.as_dict(), cls=self.Encoder) return json.dumps(self.as_dict(), cls=self.Encoder)
@ -297,6 +296,43 @@ class EventMatchResult:
parsed_args: dict = field(default_factory=dict) parsed_args: dict = field(default_factory=dict)
def deepcopy(
args: Union[dict, list], _out: Optional[Union[dict, list]] = None
) -> Union[dict, list]:
"""
Workaround implementation of deepcopy that doesn't raise exceptions
on non-pickeable objects.
"""
from platypush.plugins import Plugin
if _out is None:
_out = {} if isinstance(args, dict) else []
if isinstance(args, list):
_out = [None] * len(args)
for i, v in enumerate(args):
if isinstance(v, dict):
_out[i] = deepcopy(v)
elif isinstance(v, (list, tuple, set)):
_out[i] = deepcopy(list(v))
elif isinstance(v, Plugin):
_out[i] = get_plugin_name_by_class(v.__class__)
else:
_out[i] = v
elif isinstance(args, dict):
for k, v in args.items():
if isinstance(v, dict):
_out[k] = deepcopy(v)
elif isinstance(v, (list, tuple, set)):
_out[k] = deepcopy(list(v))
elif isinstance(v, Plugin):
_out[k] = get_plugin_name_by_class(v.__class__)
else:
_out[k] = v
return _out
def flatten(args): def flatten(args):
""" """
Flatten a nested dictionary for string serialization. Flatten a nested dictionary for string serialization.

View file

@ -1,10 +1,6 @@
import functools
import inspect
import json
import os import os
import pathlib
import queue import queue
import re
import subprocess
import tempfile import tempfile
import threading import threading
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@ -13,6 +9,7 @@ from typing import (
Iterable, Iterable,
List, List,
Optional, Optional,
Sequence,
Tuple, Tuple,
Type, Type,
Union, Union,
@ -24,16 +21,18 @@ from platypush.config import Config
from platypush.context import get_plugin, get_backend from platypush.context import get_plugin, get_backend
from platypush.message.event.media import MediaEvent from platypush.message.event.media import MediaEvent
from platypush.plugins import RunnablePlugin, action from platypush.plugins import RunnablePlugin, action
from platypush.utils import get_default_downloads_dir, get_plugin_name_by_class from platypush.utils import (
get_default_downloads_dir,
from ._download import ( get_mime_type,
DownloadState, get_plugin_name_by_class,
DownloadThread,
FileDownloadThread,
YouTubeDownloadThread,
) )
from ._constants import audio_extensions, video_extensions
from ._model import DownloadState, PlayerState
from ._resource import MediaResource from ._resource import MediaResource
from ._state import PlayerState from ._resource.downloaders import DownloadThread, MediaResourceDownloader, downloaders
from ._resource.parsers import MediaResourceParser, parsers
from ._search import MediaSearcher, searchers
class MediaPlugin(RunnablePlugin, ABC): class MediaPlugin(RunnablePlugin, ABC):
@ -51,97 +50,14 @@ class MediaPlugin(RunnablePlugin, ABC):
'This method must be implemented in a derived class' 'This method must be implemented in a derived class'
) )
# Supported audio extensions audio_extensions = audio_extensions
audio_extensions = { video_extensions = video_extensions
'3gp',
'aa',
'aac',
'aax',
'act',
'aiff',
'amr',
'ape',
'au',
'awb',
'dct',
'dss',
'dvf',
'flac',
'gsm',
'iklax',
'ivs',
'm4a',
'm4b',
'm4p',
'mmf',
'mp3',
'mpc',
'msv',
'nmf',
'nsf',
'ogg,',
'opus',
'ra,',
'raw',
'sln',
'tta',
'vox',
'wav',
'wma',
'wv',
'webm',
'8svx',
}
# Supported video extensions
video_extensions = {
'webm',
'mkv',
'flv',
'vob',
'ogv',
'ogg',
'drc',
'gif',
'gifv',
'mng',
'avi',
'mts',
'm2ts',
'mov',
'qt',
'wmv',
'yuv',
'rm',
'rmvb',
'asf',
'amv',
'mp4',
'm4p',
'm4v',
'mpg',
'mp2',
'mpeg',
'mpe',
'mpv',
'm2v',
'svi',
'3gp',
'3g2',
'mxf',
'roq',
'nsv',
'f4v',
'f4p',
'f4a',
'f4b',
}
supported_media_plugins = [ supported_media_plugins = [
'media.vlc',
'media.mpv',
'media.mplayer', 'media.mplayer',
'media.omxplayer', 'media.omxplayer',
'media.mpv',
'media.vlc',
'media.chromecast', 'media.chromecast',
'media.gstreamer', 'media.gstreamer',
] ]
@ -156,9 +72,13 @@ class MediaPlugin(RunnablePlugin, ABC):
env: Optional[Dict[str, str]] = None, env: Optional[Dict[str, str]] = None,
volume: Optional[Union[float, int]] = None, volume: Optional[Union[float, int]] = None,
torrent_plugin: str = 'torrent', torrent_plugin: str = 'torrent',
youtube_format: Optional[str] = None, youtube_format: Optional[str] = 'bv[height<=?1080]+ba/bv+ba',
youtube_audio_format: Optional[str] = 'ba',
youtube_dl: str = 'yt-dlp', youtube_dl: str = 'yt-dlp',
merge_output_format: str = 'mp4', merge_output_format: str = 'mp4',
cache_dir: Optional[str] = None,
cache_streams: bool = False,
ytdl_args: Optional[Sequence[str]] = None,
**kwargs, **kwargs,
): ):
""" """
@ -187,6 +107,9 @@ class MediaPlugin(RunnablePlugin, ABC):
``bestvideo[height<=?1080][ext=mp4]+bestaudio`` - select the best ``bestvideo[height<=?1080][ext=mp4]+bestaudio`` - select the best
mp4 video with a resolution <= 1080p, and the best audio format. mp4 video with a resolution <= 1080p, and the best audio format.
:param youtube_audio_format: Select the preferred audio format for
YouTube videos downloaded only for audio. Default: ``bestaudio``.
:param youtube_dl: Path to the ``youtube-dl`` executable, used to :param youtube_dl: Path to the ``youtube-dl`` executable, used to
extract information from YouTube videos and other media platforms. extract information from YouTube videos and other media platforms.
Default: ``yt-dlp``. The default has changed from ``youtube-dl`` to Default: ``yt-dlp``. The default has changed from ``youtube-dl`` to
@ -197,12 +120,29 @@ class MediaPlugin(RunnablePlugin, ABC):
and the upstream media contains both audio and video to be merged, and the upstream media contains both audio and video to be merged,
this can be used to specify the format of the output container - this can be used to specify the format of the output container -
e.g. ``mp4``, ``mkv``, ``avi``, ``flv``. Default: ``mp4``. e.g. ``mp4``, ``mkv``, ``avi``, ``flv``. Default: ``mp4``.
:param cache_dir: Directory where the media cache will be stored. If not
specified, the cache will be stored in the default cache directory
(usually ``~/.cache/platypush/media/<media_plugin>``).
:param cache_streams: If set to True, streams transcoded via yt-dlp or
ffmpeg will be cached in ``cache_dir`` directory. If not set
(default), then streams will be played directly via memory pipe.
You may want to set this to True if you have a slow network, or if
you want to play media at high quality, even though the start time
may be delayed. If set to False, the media will start playing as
soon as the stream is ready, but the quality may be lower,
especially at the beginning, and seeking may not be supported.
:param ytdl_args: Additional arguments to pass to the youtube-dl
executable. Default: None.
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
if media_dirs is None: if media_dirs is None:
media_dirs = [] media_dirs = []
player = None player = None
player_config = {} player_config = {}
self._download_threads: Dict[Tuple[str, str], DownloadThread] = {} self._download_threads: Dict[Tuple[str, str], DownloadThread] = {}
@ -230,6 +170,7 @@ class MediaPlugin(RunnablePlugin, ABC):
self.registered_actions.add(act) self.registered_actions.add(act)
self._env = env or {} self._env = env or {}
self.cache_streams = cache_streams
self.media_dirs = set( self.media_dirs = set(
filter( filter(
os.path.isdir, os.path.isdir,
@ -245,7 +186,18 @@ class MediaPlugin(RunnablePlugin, ABC):
) )
) )
os.makedirs(self.download_dir, exist_ok=True) self.cache_dir = os.path.abspath(
os.path.expanduser(cache_dir)
if cache_dir
else os.path.join(
Config.get_cachedir(),
'media',
get_plugin_name_by_class(self.__class__),
)
)
pathlib.Path(self.cache_dir).mkdir(parents=True, exist_ok=True)
pathlib.Path(self.download_dir).mkdir(parents=True, exist_ok=True)
self._ytdl = youtube_dl self._ytdl = youtube_dl
self.media_dirs.add(self.download_dir) self.media_dirs.add(self.download_dir)
self.volume = volume self.volume = volume
@ -253,62 +205,31 @@ class MediaPlugin(RunnablePlugin, ABC):
self._youtube_proc = None self._youtube_proc = None
self.torrent_plugin = torrent_plugin self.torrent_plugin = torrent_plugin
self.youtube_format = youtube_format self.youtube_format = youtube_format
self.youtube_audio_format = youtube_audio_format
self.merge_output_format = merge_output_format self.merge_output_format = merge_output_format
self.ytdl_args = ytdl_args or []
self._latest_resource: Optional[MediaResource] = None self._latest_resource: Optional[MediaResource] = None
@staticmethod self._parsers: Dict[Type[MediaResourceParser], MediaResourceParser] = {
def _torrent_event_handler(evt_queue): parser: parser(self) for parser in parsers
def handler(event): }
# More than 5% of the torrent has been downloaded
if event.args.get('progress', 0) > 5 and event.args.get('files'):
evt_queue.put(event.args['files'])
return handler self._downloaders: Dict[
Type[MediaResourceDownloader], MediaResourceDownloader
] = {downloader: downloader(self) for downloader in downloaders}
def get_extractors(self): self._searchers: Dict[Type[MediaSearcher], MediaSearcher] = {
try: searcher: searcher(dirs=self.media_dirs, media_plugin=self)
from yt_dlp.extractor import _extractors # type: ignore for searcher in searchers
except ImportError: }
self.logger.debug('yt_dlp not installed')
return
for _, obj_type in inspect.getmembers(_extractors): def _get_resource(
if ( self,
inspect.isclass(obj_type) resource: str,
and isinstance(getattr(obj_type, "_VALID_URL", None), str) metadata: Optional[dict] = None,
and obj_type.__name__ != "GenericIE" only_audio: bool = False,
): **_,
yield obj_type ):
def _is_youtube_resource(self, resource: str):
return any(
re.search(getattr(extractor, '_VALID_URL', '^$'), resource)
for extractor in self.get_extractors()
)
def _get_youtube_best_thumbnail(self, info: Dict[str, dict]):
thumbnails = info.get('thumbnails', {})
if not thumbnails:
return None
# Preferred resolution
for res in ((640, 480), (480, 360), (320, 240)):
thumb = next(
(
thumb
for thumb in thumbnails
if thumb.get('width') == res[0] and thumb.get('height') == res[1]
),
None,
)
if thumb:
return thumb.get('url')
# Default fallback (best quality)
return info.get('thumbnail')
def _get_resource(self, resource: str):
""" """
:param resource: Resource to play/parse. Supported types: :param resource: Resource to play/parse. Supported types:
@ -319,72 +240,19 @@ class MediaPlugin(RunnablePlugin, ABC):
""" """
if resource.startswith('file://'): for parser in self._parsers.values():
path = resource[len('file://') :] media_resource = parser.parse(resource, only_audio=only_audio)
assert os.path.isfile(path), f'File {path} not found' if media_resource:
self._latest_resource = MediaResource( for k, v in (metadata or {}).items():
resource=resource, setattr(media_resource, k, v)
url=resource,
title=os.path.basename(resource),
filename=os.path.basename(resource),
)
elif self._is_youtube_resource(resource):
info = self._get_youtube_info(resource)
if info:
url = info.get('url')
if url:
resource = url
self._latest_resource = MediaResource(
resource=resource,
url=resource,
title=info.get('title'),
description=info.get('description'),
filename=info.get('filename'),
image=info.get('thumbnail'),
duration=float(info.get('duration') or 0) or None,
channel=info.get('channel'),
channel_url=info.get('channel_url'),
resolution=info.get('resolution'),
type=info.get('extractor'),
)
elif resource.startswith('magnet:?'):
self.logger.info(
'Downloading torrent %s to %s', resource, self.download_dir
)
torrents = get_plugin(self.torrent_plugin)
assert torrents, f'{self.torrent_plugin} plugin not configured'
evt_queue = queue.Queue() return media_resource
torrents.download(
resource,
download_dir=self.download_dir,
_async=True,
is_media=True,
event_hndl=self._torrent_event_handler(evt_queue),
)
resources = [f for f in evt_queue.get()] # noqa: C416,R1721 raise AssertionError(f'Unknown media resource: {resource}')
if resources:
self._videos_queue = sorted(resources)
resource = self._videos_queue.pop(0)
else:
raise RuntimeError(f'No media file found in torrent {resource}')
assert resource, 'Unable to find any compatible media resource'
return resource
def _stop_torrent(self):
try:
torrents = get_plugin(self.torrent_plugin)
assert torrents, f'{self.torrent_plugin} plugin not configured'
torrents.quit()
except Exception as e:
self.logger.warning('Could not stop torrent plugin: %s', e)
@action @action
@abstractmethod @abstractmethod
def play(self, resource, **kwargs): def play(self, resource: str, **kwargs):
raise self._NOT_IMPLEMENTED_ERR raise self._NOT_IMPLEMENTED_ERR
@action @action
@ -567,43 +435,45 @@ class MediaPlugin(RunnablePlugin, ABC):
return thread return thread
def _get_search_handler_by_type(self, search_type: str): def _get_search_handler_by_type(self, search_type: str):
if search_type == 'file': searcher = next(
from .search import LocalMediaSearcher iter(filter(lambda s: s.supports(search_type), self._searchers.values())),
None,
)
return LocalMediaSearcher(self.media_dirs, media_plugin=self) if not searcher:
if search_type == 'torrent': self.logger.warning('Unsupported search type: %s', search_type)
from .search import TorrentMediaSearcher return None
return TorrentMediaSearcher(media_plugin=self) return searcher
if search_type == 'youtube':
from .search import YoutubeMediaSearcher
return YoutubeMediaSearcher(media_plugin=self)
if search_type == 'plex':
from .search import PlexMediaSearcher
return PlexMediaSearcher(media_plugin=self)
if search_type == 'jellyfin':
from .search import JellyfinMediaSearcher
return JellyfinMediaSearcher(media_plugin=self)
self.logger.warning('Unsupported search type: %s', search_type)
return None
@classmethod @classmethod
def is_video_file(cls, filename: str): def is_video_file(cls, filename: str):
return filename.lower().split('.')[-1] in cls.video_extensions if filename.lower().split('.')[-1] in cls.video_extensions:
return True
mime_type = get_mime_type(filename)
return bool(mime_type and mime_type.startswith('video/'))
@classmethod @classmethod
def is_audio_file(cls, filename: str): def is_audio_file(cls, filename: str):
return filename.lower().split('.')[-1] in cls.audio_extensions if filename.lower().split('.')[-1] in cls.audio_extensions:
return True
def _get_info(self, resource: str): mime_type = get_mime_type(filename)
if self._is_youtube_resource(resource): return bool(mime_type and mime_type.startswith('audio/'))
return self.get_youtube_info(resource)
return {'url': resource} @classmethod
def is_media_file(cls, file: str) -> bool:
if file.split('.')[-1].lower() in cls.video_extensions.union(
cls.audio_extensions
):
return True
mime_type = get_mime_type(file)
return bool(
mime_type
and (mime_type.startswith('video/') or mime_type.startswith('audio/'))
)
@action @action
def start_streaming( def start_streaming(
@ -660,109 +530,14 @@ class MediaPlugin(RunnablePlugin, ABC):
assert response.ok, response.text or response.reason assert response.ok, response.text or response.reason
return response.json() return response.json()
def _get_youtube_info(self, url, youtube_format: Optional[str] = None):
ytdl_cmd = [
self._ytdl,
*(
['-f', youtube_format or self.youtube_format]
if youtube_format or self.youtube_format
else []
),
'-j',
'-g',
url,
]
self.logger.info('Executing command %s', ' '.join(ytdl_cmd))
with subprocess.Popen(ytdl_cmd, stdout=subprocess.PIPE) as ytdl:
output = ytdl.communicate()[0].decode().strip()
ytdl.wait()
self.logger.debug('yt-dlp output: %s', output)
lines = output.split('\n')
if not lines:
self.logger.warning('No output from yt-dlp')
return None
stream_url = lines[1] if len(lines) > 2 else lines[0]
info = lines[-1]
return {
**json.loads(info),
'url': stream_url,
}
@staticmethod
def get_youtube_id(url: str) -> Optional[str]:
patterns = [
re.compile(pattern)
for pattern in [
r'https?://www.youtube.com/watch\?v=([^&#]+)',
r'https?://youtube.com/watch\?v=([^&#]+)',
r'https?://youtu.be/([^&#/]+)',
r'youtube:video:([^&#:])',
]
]
for pattern in patterns:
m = pattern.search(url)
if m:
return m.group(1)
return None
@action
def get_youtube_info(self, url):
# Legacy conversion for Mopidy YouTube URIs
m = re.match('youtube:video:(.*)', url)
if m:
url = f'https://www.youtube.com/watch?v={m.group(1)}'
with subprocess.Popen([self._ytdl, '-j', url], stdout=subprocess.PIPE) as proc:
if proc.stdout is None:
return None
return proc.stdout.read().decode("utf-8", "strict")[:-1]
@action @action
def get_info(self, resource: str): def get_info(self, resource: str):
return self._get_info(resource) for parser in self._parsers.values():
info = parser.parse(resource)
if info:
return info.to_dict()
@action return {'url': resource}
def get_media_file_duration(self, filename):
"""
Get the duration of a media file in seconds. Requires ffmpeg
"""
if filename.startswith('file://'):
filename = filename[7:]
with subprocess.Popen(
["ffprobe", filename], stdout=subprocess.PIPE, stderr=subprocess.STDOUT
) as result:
if not result.stdout:
return 0
return functools.reduce(
lambda t, t_i: t + t_i,
[
float(t) * pow(60, i)
for (i, t) in enumerate(
re.search(
r'^Duration:\s*([^,]+)',
[
x.decode()
for x in result.stdout.readlines()
if "Duration" in x.decode()
]
.pop()
.strip(),
)
.group(1) # type: ignore
.split(':')[::-1]
)
],
)
@action @action
def download( def download(
@ -774,6 +549,7 @@ class MediaPlugin(RunnablePlugin, ABC):
sync: bool = False, sync: bool = False,
only_audio: bool = False, only_audio: bool = False,
youtube_format: Optional[str] = None, youtube_format: Optional[str] = None,
youtube_audio_format: Optional[str] = None,
merge_output_format: Optional[str] = None, merge_output_format: Optional[str] = None,
): ):
""" """
@ -802,25 +578,46 @@ class MediaPlugin(RunnablePlugin, ABC):
:param only_audio: If set to True, only the audio track will be downloaded :param only_audio: If set to True, only the audio track will be downloaded
(only supported for yt-dlp-compatible URLs for now). (only supported for yt-dlp-compatible URLs for now).
:param youtube_format: Override the default ``youtube_format`` setting. :param youtube_format: Override the default ``youtube_format`` setting.
:param youtube_audio_format: Override the default ``youtube_audio_format``
:param merge_output_format: Override the default :param merge_output_format: Override the default
``merge_output_format`` setting. ``merge_output_format`` setting.
:return: The absolute path to the downloaded file. :return: The absolute path to the downloaded file.
""" """
path = self._get_download_path( dl_thread = None
url, directory=directory, filename=filename, youtube_format=youtube_format resource = self._get_resource(url, only_audio=only_audio)
)
if self._is_youtube_resource(url): if filename:
dl_thread = self._download_youtube_url( path = os.path.expanduser(filename)
url, path, youtube_format=youtube_format, only_audio=only_audio elif resource.filename:
) path = resource.filename
else: else:
if only_audio: path = os.path.basename(resource.url)
self.logger.warning(
'Only audio download is not supported for non-YouTube URLs' if not os.path.isabs(path):
directory = os.path.expanduser(directory or self.download_dir)
path = os.path.join(directory, path)
for downloader in self._downloaders.values():
if downloader.supports(resource):
if only_audio and not downloader.supports_only_audio():
self.logger.warning(
'Only audio download is not supported for this resource'
)
dl_thread = downloader.download(
resource=resource,
path=path,
timeout=timeout,
only_audio=only_audio,
youtube_format=youtube_format or self.youtube_format,
youtube_audio_format=youtube_audio_format
or self.youtube_audio_format,
merge_output_format=merge_output_format,
) )
dl_thread = self._download_url(url, path, timeout=timeout) break
assert dl_thread, f'No downloader found for resource {url}'
if sync: if sync:
dl_thread.join() dl_thread.join()
@ -934,90 +731,10 @@ class MediaPlugin(RunnablePlugin, ABC):
assert threads, f'No matching downloads found for [url={url}, path={path}]' assert threads, f'No matching downloads found for [url={url}, path={path}]'
return threads return threads
def _get_download_path( def on_download_start(self, thread: DownloadThread):
self,
url: str,
directory: Optional[str] = None,
filename: Optional[str] = None,
youtube_format: Optional[str] = None,
) -> str:
if not directory:
directory = self.download_dir
directory = os.path.expanduser(directory)
youtube_format = youtube_format or self.youtube_format
if self._is_youtube_resource(url):
with subprocess.Popen(
[
self._ytdl,
*(
[
'-f',
youtube_format,
]
if youtube_format
else []
),
*(
['--merge-output-format', self.merge_output_format]
if self.merge_output_format
else []
),
'-O',
'%(title)s.%(ext)s',
url,
],
stdout=subprocess.PIPE,
) as proc:
assert proc.stdout, 'yt-dlp stdout is None'
filename = proc.stdout.read().decode()[:-1]
if not filename:
filename = url.split('/')[-1]
return os.path.join(directory, filename)
def _download_url(self, url: str, path: str, timeout: int) -> FileDownloadThread:
download_thread = FileDownloadThread(
url=url,
path=path,
timeout=timeout,
on_start=self._on_download_start,
post_event=self._post_event,
stop_event=self._should_stop,
)
self._start_download(download_thread)
return download_thread
def _download_youtube_url(
self,
url: str,
path: str,
youtube_format: Optional[str] = None,
merge_output_format: Optional[str] = None,
only_audio: bool = False,
) -> YouTubeDownloadThread:
download_thread = YouTubeDownloadThread(
url=url,
path=path,
ytdl=self._ytdl,
only_audio=only_audio,
youtube_format=youtube_format or self.youtube_format,
merge_output_format=merge_output_format or self.merge_output_format,
on_start=self._on_download_start,
post_event=self._post_event,
stop_event=self._should_stop,
)
self._start_download(download_thread)
return download_thread
def _on_download_start(self, thread: DownloadThread):
self._download_threads[thread.url, thread.path] = thread self._download_threads[thread.url, thread.path] = thread
def _start_download(self, thread: DownloadThread): def start_download(self, thread: DownloadThread):
if (thread.url, thread.path) in self._download_threads: if (thread.url, thread.path) in self._download_threads:
self.logger.warning( self.logger.warning(
'A download of %s to %s is already in progress', thread.url, thread.path 'A download of %s to %s is already in progress', thread.url, thread.path
@ -1026,7 +743,7 @@ class MediaPlugin(RunnablePlugin, ABC):
thread.start() thread.start()
def _post_event(self, event_type: Type[MediaEvent], **kwargs): def post_event(self, event_type: Type[MediaEvent], **kwargs):
evt = event_type( evt = event_type(
player=get_plugin_name_by_class(self.__class__), plugin=self, **kwargs player=get_plugin_name_by_class(self.__class__), plugin=self, **kwargs
) )
@ -1054,6 +771,14 @@ class MediaPlugin(RunnablePlugin, ABC):
f.write(content) f.write(content)
return f.name return f.name
@property
def supports_local_media(self) -> bool:
return True
@property
def supports_local_pipe(self) -> bool:
return True
def main(self): def main(self):
self.wait_stop() self.wait_stop()
@ -1061,6 +786,8 @@ class MediaPlugin(RunnablePlugin, ABC):
__all__ = [ __all__ = [
'DownloadState', 'DownloadState',
'MediaPlugin', 'MediaPlugin',
'MediaResource',
'MediaSearcher',
'PlayerState', 'PlayerState',
] ]

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.context import get_plugin
from platypush.plugins.media.search import MediaSearcher from platypush.plugins.media._search import MediaSearcher
class JellyfinMediaSearcher(MediaSearcher): class JellyfinMediaSearcher(MediaSearcher):
def search(self, query, **_): """
Jellyfin media searcher.
"""
def supports(self, type: str) -> bool:
return type == 'jellyfin'
def search(self, query, *_, **__):
""" """
Performs a search on a Jellyfin server using the configured Performs a search on a Jellyfin server using the configured
:class:`platypush.plugins.media.jellyfin.MediaJellyfinPlugin` :class:`platypush.plugins.media.jellyfin.MediaJellyfinPlugin`
@ -18,9 +25,11 @@ class JellyfinMediaSearcher(MediaSearcher):
if not media: if not media:
return [] return []
self.logger.info('Searching Jellyfin for "{}"'.format(query)) self.logger.info('Searching Jellyfin for "%s"', query)
results = media.search(query=query).output results = media.search(query=query).output
self.logger.info('{} Jellyfin results found for the search query "{}"'.format(len(results), query)) self.logger.info(
'%d Jellyfin results found for the search query "%s"', len(results), query
)
return results return results

View file

@ -8,8 +8,7 @@ from sqlalchemy import create_engine
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from platypush.config import Config from platypush.config import Config
from platypush.plugins.media import MediaPlugin from platypush.plugins.media._search import MediaSearcher
from platypush.plugins.media.search import MediaSearcher
from .db import ( from .db import (
Base, Base,
@ -43,6 +42,9 @@ class LocalMediaSearcher(MediaSearcher):
self.db_file = os.path.join(db_dir, 'media.db') self.db_file = os.path.join(db_dir, 'media.db')
self._db_engine = None self._db_engine = None
def supports(self, type: str) -> bool:
return type == 'file'
def _get_db_session(self): def _get_db_session(self):
if not self._db_engine: if not self._db_engine:
self._db_engine = create_engine( self._db_engine = create_engine(
@ -124,6 +126,7 @@ class LocalMediaSearcher(MediaSearcher):
Scans a media directory and stores the search results in the internal Scans a media directory and stores the search results in the internal
SQLite index SQLite index
""" """
from platypush.plugins.media import MediaPlugin
if not session: if not session:
session = self._get_db_session() session = self._get_db_session()

View file

@ -3,11 +3,11 @@ import json
import logging import logging
import multiprocessing import multiprocessing
import os import os
import shutil
import subprocess import subprocess
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from dateutil.parser import isoparse from dateutil.parser import isoparse
import shutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -58,6 +58,8 @@ def get_file_metadata(path: str):
'duration': ret.get('format', {}).get('duration'), 'duration': ret.get('format', {}).get('duration'),
'width': video_stream.get('width'), 'width': video_stream.get('width'),
'height': video_stream.get('height'), 'height': video_stream.get('height'),
'resolution': f"{video_stream.get('width')}x{video_stream.get('height')}",
'size': os.path.getsize(path),
'created_at': creation_time, 'created_at': creation_time,
} }

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.context import get_plugin
from platypush.plugins.media.search import MediaSearcher from platypush.plugins.media._search import MediaSearcher
# pylint: disable=too-few-public-methods
class TorrentMediaSearcher(MediaSearcher): class TorrentMediaSearcher(MediaSearcher):
def search(self, query, **kwargs): """
self.logger.info('Searching torrents for "{}"'.format(query)) Media searcher for torrents.
It needs at least one torrent plugin to be configured.
"""
def search(self, query: str, *_, **__):
self.logger.info('Searching torrents for "%s"', query)
torrents = get_plugin( torrents = get_plugin(
self.media_plugin.torrent_plugin if self.media_plugin else 'torrent' self.media_plugin.torrent_plugin if self.media_plugin else 'torrent'
) )
if not torrents: if not torrents:
raise RuntimeError('Torrent plugin not available/configured') raise RuntimeError('Torrent plugin not available/configured')
@ -20,5 +28,8 @@ class TorrentMediaSearcher(MediaSearcher):
if torrent.get('is_media') if torrent.get('is_media')
] ]
def supports(self, type: str) -> bool:
return type == 'torrent'
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -1,5 +1,5 @@
from platypush.context import get_plugin from platypush.context import get_plugin
from platypush.plugins.media.search import MediaSearcher from platypush.plugins.media._search import MediaSearcher
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
@ -18,5 +18,8 @@ class YoutubeMediaSearcher(MediaSearcher):
assert yt, 'YouTube plugin not available/configured' assert yt, 'YouTube plugin not available/configured'
return yt.search(query=query).output return yt.search(query=query).output
def supports(self, type: str) -> bool:
return type == 'youtube'
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

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 ( from pychromecast import (
CastBrowser, CastBrowser,
@ -10,9 +11,14 @@ from pychromecast import (
from platypush.backend.http.app.utils import get_remote_base_url from platypush.backend.http.app.utils import get_remote_base_url
from platypush.plugins import RunnablePlugin, action from platypush.plugins import RunnablePlugin, action
from platypush.plugins.media import MediaPlugin from platypush.plugins.media import MediaPlugin, MediaResource
from platypush.plugins.media._resource.youtube import YoutubeMediaResource
from platypush.utils import get_mime_type from platypush.utils import get_mime_type
from platypush.message.event.media import MediaPlayRequestEvent from platypush.message.event.media import (
MediaEvent,
MediaPlayRequestEvent,
MediaStopEvent,
)
from ._listener import MediaListener from ._listener import MediaListener
from ._subtitles import SubtitlesAsyncHandler from ._subtitles import SubtitlesAsyncHandler
@ -29,22 +35,49 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
STREAM_TYPE_LIVE = "LIVE" STREAM_TYPE_LIVE = "LIVE"
def __init__( def __init__(
self, chromecast: Optional[str] = None, poll_interval: float = 30, **kwargs self,
chromecast: Optional[str] = None,
poll_interval: float = 30,
youtube_format: Optional[str] = 'bv[width<=?1080][ext=mp4]+ba[ext=m4a]/bv+ba',
merge_output_format: str = 'mp4',
# Transcode to H.264/AAC to maximimze compatibility with Chromecast codecs
ytdl_args: Optional[Sequence[str]] = (
'--use-postprocessor',
'FFmpegCopyStream',
'--ppa',
'CopyStream:"-c:v libx264 -preset veryfast -crf 28 +faststart -c:a aac"',
),
use_ytdl: bool = True,
**kwargs,
): ):
""" """
:param chromecast: Default Chromecast to cast to if no name is specified. :param chromecast: Default Chromecast to cast to if no name is specified.
:param poll_interval: How often the plugin should poll for new/removed :param poll_interval: How often the plugin should poll for new/removed
Chromecast devices (default: 30 seconds). Chromecast devices (default: 30 seconds).
:param use_ytdl: Use youtube-dl to download the media if we are dealing
with formats compatible with youtube-dl/yt-dlp. Disable this option
if you experience issues with the media playback on the Chromecast,
such as media with no video, no audio or both. This option will
disable muxing+transcoding over the HTTP server and will stream the
URL directly to the Chromecast.
""" """
super().__init__(poll_interval=poll_interval, **kwargs) super().__init__(
poll_interval=poll_interval,
youtube_format=youtube_format,
merge_output_format=merge_output_format,
ytdl_args=ytdl_args,
**kwargs,
)
self._is_local = False self._is_local = False
self.chromecast = chromecast self.chromecast = chromecast
self._chromecasts_by_uuid = {} self._chromecasts_by_uuid = {}
self._chromecasts_by_name = {} self._chromecasts_by_name = {}
self._media_listeners = {} self._media_listeners = {}
self._latest_resources_by_device: Dict[UUID, MediaResource] = {}
self._zc = None self._zc = None
self._browser = None self._browser = None
self._use_ytdl = use_ytdl
@property @property
def zc(self): def zc(self):
@ -90,6 +123,9 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
else: else:
raise RuntimeError('Invalid Chromecast object') raise RuntimeError('Invalid Chromecast object')
resource = self._latest_resources_by_device.get(cc.uuid)
resource_dump = resource.to_dict() if resource else {}
return { return {
'type': cc.cast_type, 'type': cc.cast_type,
'name': cc.name, 'name': cc.name,
@ -111,19 +147,25 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
'volume': round(100 * cc.status.volume_level, 2), 'volume': round(100 * cc.status.volume_level, 2),
'muted': cc.status.volume_muted, 'muted': cc.status.volume_muted,
**convert_status(cc.media_controller.status), **convert_status(cc.media_controller.status),
**resource_dump,
} }
if cc.status if cc.status
else {} else {}
), ),
} }
def _event_callback(self, _, cast: Chromecast): def _event_callback(self, evt: MediaEvent, cast: Chromecast):
if isinstance(evt, MediaStopEvent):
resource = self._latest_resources_by_device.pop(cast.uuid, None)
if resource:
resource.close()
self._chromecasts_by_uuid[cast.uuid] = cast self._chromecasts_by_uuid[cast.uuid] = cast
self._chromecasts_by_name[ self._chromecasts_by_name[
self._get_device_property(cast, 'friendly_name') self._get_device_property(cast, 'friendly_name')
] = cast ] = cast
def get_chromecast(self, chromecast=None): def get_chromecast(self, chromecast=None) -> Chromecast:
if isinstance(chromecast, Chromecast): if isinstance(chromecast, Chromecast):
return chromecast return chromecast
@ -151,7 +193,9 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
subtitles_lang: str = 'en-US', subtitles_lang: str = 'en-US',
subtitles_mime: str = 'text/vtt', subtitles_mime: str = 'text/vtt',
subtitle_id: int = 1, subtitle_id: int = 1,
**__, youtube_format: Optional[str] = None,
use_ytdl: Optional[bool] = None,
**kwargs,
): ):
""" """
Cast media to an available Chromecast device. Cast media to an available Chromecast device.
@ -171,6 +215,8 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
:param subtitles_lang: Subtitles language (default: en-US) :param subtitles_lang: Subtitles language (default: en-US)
:param subtitles_mime: Subtitles MIME type (default: text/vtt) :param subtitles_mime: Subtitles MIME type (default: text/vtt)
:param subtitle_id: ID of the subtitles to be loaded (default: 1) :param subtitle_id: ID of the subtitles to be loaded (default: 1)
:param youtube_format: Override the default YouTube format.
:param use_ytdl: Override the default use_ytdl setting for this call.
""" """
if not chromecast: if not chromecast:
@ -179,10 +225,26 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
post_event(MediaPlayRequestEvent, resource=resource, device=chromecast) post_event(MediaPlayRequestEvent, resource=resource, device=chromecast)
cast = self.get_chromecast(chromecast) cast = self.get_chromecast(chromecast)
mc = cast.media_controller mc = cast.media_controller
resource = self._get_resource(resource) media = self._latest_resource = self._latest_resources_by_device[
cast.uuid
] = self._get_resource(resource, **kwargs)
youtube_format = youtube_format or self.youtube_format
use_ytdl = use_ytdl if use_ytdl is not None else self._use_ytdl
ytdl_args = kwargs.pop('ytdl_args', self.ytdl_args)
if isinstance(media, YoutubeMediaResource) and not use_ytdl:
# Use the original URL if it's a YouTube video and we're not using youtube-dl
media.resource = media.url
else:
media.open(youtube_format=youtube_format, ytdl_args=ytdl_args, **kwargs)
assert media.resource, 'No playable resource found'
resource = media.resource
self.logger.debug('Opened media resource: %s', media.to_dict())
if not content_type: if not content_type:
content_type = get_mime_type(resource) content_type = get_mime_type(media.resource)
if not content_type: if not content_type:
raise RuntimeError(f'content_type required to process media {resource}') raise RuntimeError(f'content_type required to process media {resource}')
@ -192,19 +254,13 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
resource = get_remote_base_url() + resource resource = get_remote_base_url() + resource
self.logger.info('HTTP media stream started on %s', resource) self.logger.info('HTTP media stream started on %s', resource)
if self._latest_resource:
if not title:
title = self._latest_resource.title
if not image_url:
image_url = self._latest_resource.image
self.logger.info('Playing %s on %s', resource, chromecast) self.logger.info('Playing %s on %s', resource, chromecast)
mc.play_media( mc.play_media(
resource, resource,
content_type, content_type,
title=self._latest_resource.title if self._latest_resource else title, title=title or media.title,
thumb=image_url, thumb=image_url or media.image,
current_time=current_time, current_time=current_time,
autoplay=autoplay, autoplay=autoplay,
stream_type=stream_type, stream_type=stream_type,
@ -629,6 +685,15 @@ class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
self._chromecasts_by_uuid[cc.uuid] = cc self._chromecasts_by_uuid[cc.uuid] = cc
self._chromecasts_by_name[name] = cc self._chromecasts_by_name[name] = cc
@property
def supports_local_media(self) -> bool:
# Chromecasts can't play local media: they always need an HTTP URL
return False
@property
def supports_local_pipe(self) -> bool:
return False
def main(self): def main(self):
while not self.should_stop(): while not self.should_stop():
try: try:

View file

@ -1,8 +1,9 @@
import logging import logging
import time import time
from typing import Optional from typing import Optional, Type
from platypush.message.event.media import ( from platypush.message.event.media import (
MediaEvent,
MediaPlayEvent, MediaPlayEvent,
MediaStopEvent, MediaStopEvent,
MediaPauseEvent, MediaPauseEvent,
@ -42,7 +43,10 @@ class MediaListener:
self._post_event(MediaPlayEvent) self._post_event(MediaPlayEvent)
elif state == 'pause': elif state == 'pause':
self._post_event(MediaPauseEvent) self._post_event(MediaPauseEvent)
elif state in ('stop', 'idle'): elif state in ('stop', 'idle') and self.status.get('state') in (
'play',
'pause',
):
self._post_event(MediaStopEvent) self._post_event(MediaStopEvent)
if status.get('volume') != self.status.get('volume'): if status.get('volume') != self.status.get('volume'):
@ -62,7 +66,7 @@ class MediaListener:
def load_media_failed(self, item, error_code): def load_media_failed(self, item, error_code):
logger.warning('Failed to load media %s: %d', item, error_code) logger.warning('Failed to load media %s: %d', item, error_code)
def _post_event(self, evt_type, **evt): def _post_event(self, evt_type: Type[MediaEvent], **evt):
status = evt.get('status', {}) status = evt.get('status', {})
resource = status.get('url') resource = status.get('url')
args = { args = {

View file

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

View file

@ -2,41 +2,64 @@ from typing import Type
from platypush.common.gstreamer import Pipeline from platypush.common.gstreamer import Pipeline
from platypush.context import get_bus from platypush.context import get_bus
from platypush.message.event.media import MediaEvent, MediaPlayEvent, MediaPauseEvent, MediaStopEvent, \ from platypush.message.event.media import (
NewPlayingMediaEvent, MediaMuteChangedEvent, MediaSeekEvent MediaEvent,
MediaPlayEvent,
MediaPauseEvent,
MediaStopEvent,
NewPlayingMediaEvent,
MediaMuteChangedEvent,
MediaSeekEvent,
)
from platypush.plugins.media import MediaResource
class MediaPipeline(Pipeline): class MediaPipeline(Pipeline):
def __init__(self, resource: str): def __init__(self, resource: MediaResource):
super().__init__() super().__init__()
self.resource = resource
self.add_source('playbin', uri=resource)
@staticmethod self.resource = resource
def post_event(evt_class: Type[MediaEvent], **kwargs): self._first_play = True
if resource.resource and resource.fd is None:
self.add_source('playbin', uri=resource.resource)
elif resource.fd is not None:
self.add_source('playbin', uri=f'fd://{resource.fd.fileno()}')
else:
raise AssertionError('No resource specified')
def post_event(self, evt_class: Type[MediaEvent], **kwargs):
kwargs['player'] = 'local' kwargs['player'] = 'local'
kwargs['plugin'] = 'media.gstreamer' kwargs['plugin'] = 'media.gstreamer'
if self.resource:
resource_args = self.resource.to_dict()
resource_args.pop('type', None)
kwargs.update(resource_args)
evt = evt_class(**kwargs) evt = evt_class(**kwargs)
get_bus().post(evt) get_bus().post(evt)
def play(self): def on_play(self):
# noinspection PyUnresolvedReferences,PyPackageRequirements super().on_play()
from gi.repository import Gst if self._first_play:
is_first_play = self.get_state() == Gst.State.NULL
super().play()
if is_first_play:
self.post_event(NewPlayingMediaEvent, resource=self.resource) self.post_event(NewPlayingMediaEvent, resource=self.resource)
self.post_event(MediaPlayEvent, resource=self.resource) self._first_play = False
def pause(self): self.post_event(MediaPlayEvent)
# noinspection PyUnresolvedReferences,PyPackageRequirements
def on_pause(self):
super().on_pause()
self.post_event(MediaPauseEvent)
def play(self):
from gi.repository import Gst from gi.repository import Gst
super().pause()
self.post_event(MediaPauseEvent if self.get_state() == Gst.State.PAUSED else MediaPlayEvent) self._first_play = self.get_state() == Gst.State.NULL
super().play()
def stop(self): def stop(self):
super().stop() super().stop()
self._first_play = True
self.post_event(MediaStopEvent) self.post_event(MediaStopEvent)
def mute(self): def mute(self):
@ -48,7 +71,20 @@ class MediaPipeline(Pipeline):
self.post_event(MediaMuteChangedEvent, mute=self.is_muted()) self.post_event(MediaMuteChangedEvent, mute=self.is_muted())
def seek(self, position: float): def seek(self, position: float):
super().seek(position) from gi.repository import Gst
if not self.source:
self.logger.info('Cannot seek on a pipeline without a source')
return
position = max(0, position)
duration = self.get_duration()
if duration and position > duration:
position = duration
cur_pos = self.get_position() or 0
seek_ns = int((position - cur_pos) * 1e9)
self.source.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH, seek_ns)
self.post_event(MediaSeekEvent, position=self.get_position()) self.post_event(MediaSeekEvent, position=self.get_position())

View file

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

View file

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

View file

@ -1,10 +1,10 @@
import os import os
from dataclasses import asdict
from typing import Any, Dict, Optional, Type from typing import Any, Dict, Optional, Type
from urllib.parse import quote from urllib.parse import quote
from platypush.plugins import action from platypush.plugins import action
from platypush.plugins.media import PlayerState, MediaPlugin from platypush.plugins.media import PlayerState, MediaPlugin
from platypush.plugins.media._resource import MediaResource, YoutubeMediaResource
from platypush.message.event.media import ( from platypush.message.event.media import (
MediaEvent, MediaEvent,
MediaPlayEvent, MediaPlayEvent,
@ -23,7 +23,6 @@ class MediaMpvPlugin(MediaPlugin):
""" """
_default_mpv_args = { _default_mpv_args = {
'ytdl': True,
'start_event_thread': True, 'start_event_thread': True,
} }
@ -49,16 +48,42 @@ class MediaMpvPlugin(MediaPlugin):
self._player = None self._player = None
self._latest_state = PlayerState.STOP self._latest_state = PlayerState.STOP
def _init_mpv(self, args=None): def _init_mpv(
self,
args: Optional[dict] = None,
resource: Optional[MediaResource] = None,
youtube_format: Optional[str] = None,
youtube_audio_format: Optional[str] = None,
only_audio: bool = False,
):
import mpv import mpv
mpv_args = self.args.copy() mpv_args: dict = {**self.args}
if isinstance(resource, YoutubeMediaResource):
youtube_format = youtube_format or self.youtube_format
if only_audio:
youtube_format = (
youtube_audio_format or self.youtube_audio_format or youtube_format
)
mpv_args.update(
{
'ytdl': True,
'ytdl_format': youtube_format,
'script_opts': f'ytdl_hook-ytdl-path={self._ytdl}',
}
)
if args: if args:
mpv_args.update(args) mpv_args.update(args)
mpv_args.pop('metadata', None)
for k, v in self._env.items(): for k, v in self._env.items():
os.environ[k] = v os.environ[k] = v
self.logger.debug('Initializing mpv with args: %s', mpv_args)
self._player = mpv.MPV(**mpv_args) self._player = mpv.MPV(**mpv_args)
self._player._event_callbacks += [self._event_callback()] self._player._event_callbacks += [self._event_callback()]
@ -159,6 +184,10 @@ class MediaMpvPlugin(MediaPlugin):
*_, *_,
subtitles: Optional[str] = None, subtitles: Optional[str] = None,
fullscreen: Optional[bool] = None, fullscreen: Optional[bool] = None,
youtube_format: Optional[str] = None,
youtube_audio_format: Optional[str] = None,
only_audio: bool = False,
metadata: Optional[Dict[str, Any]] = None,
**args, **args,
): ):
""" """
@ -168,20 +197,31 @@ class MediaMpvPlugin(MediaPlugin):
:param subtitles: Path to optional subtitle file :param subtitles: Path to optional subtitle file
:param args: Extra runtime arguments that will be passed to the :param args: Extra runtime arguments that will be passed to the
mpv executable as a key-value dict (keys without `--` prefix) mpv executable as a key-value dict (keys without `--` prefix)
:param fullscreen: Override the default fullscreen setting.
:param youtube_format: Override the default youtube format setting.
:param youtube_audio_format: Override the default youtube audio format
setting.
:param only_audio: Set to True if you want to play only the audio of a
youtube video.
:param metadata: Optional metadata to attach to the resource.
""" """
self._post_event(MediaPlayRequestEvent, resource=resource) self._post_event(MediaPlayRequestEvent, resource=resource)
if fullscreen is not None: if fullscreen is not None:
args['fs'] = fullscreen args['fs'] = fullscreen
self._init_mpv(args) media = self._latest_resource = self._get_resource(resource, metadata=metadata)
self._init_mpv(
resource = self._get_resource(resource) args,
if resource.startswith('file://'): resource=media,
resource = resource[7:] youtube_format=youtube_format,
youtube_audio_format=youtube_audio_format,
only_audio=only_audio,
)
assert self._cur_player, 'The player is not ready' assert self._cur_player, 'The player is not ready'
self._cur_player.play(resource) self._cur_player.play(media.resource or media.url)
if self.volume: if self.volume:
self.set_volume(volume=self.volume) self.set_volume(volume=self.volume)
if subtitles: if subtitles:
@ -498,7 +538,7 @@ class MediaMpvPlugin(MediaPlugin):
status.update( status.update(
{ {
k: v k: v
for k, v in asdict(self._latest_resource).items() for k, v in self._latest_resource.to_dict().items()
if v is not None if v is not None
} }
) )
@ -516,11 +556,9 @@ class MediaMpvPlugin(MediaPlugin):
self._latest_state = self._state self._latest_state = self._state
return status return status
def _get_resource(self, resource): @property
if self._is_youtube_resource(resource): def supports_local_pipe(self) -> bool:
return resource # mpv can handle YouTube streaming natively return False
return super()._get_resource(resource)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

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