Writing a Media Playback Backend Plugin¶
In a nutshell
This page teaches you how to write an ovos-media playback backend:
which base class to subclass, what each method contract expects, how to register the
entry point, and how to test the result. It covers the opm.media.* family only. The
old audio service (mycroft.plugin.audioservice) is a separate, deprecated interface.
See OCP Audio Plugin if you still need to target it. Looking for
a plugin to use instead of writing one? Go to Media Plugins.
A media backend plays one track at a time. OCP owns the playlist. Your plugin only
starts, stops, pauses, resumes, and reports position/length for whatever URI it was just
handed. All three families subclass ovos_plugin_manager.templates.media.MediaBackend
through a media-type-specific parent that only differs in which TrackState it emits on
load_track/ocp_start:
| Your plugin plays... | Base class | Group | .config group |
|---|---|---|---|
| audio only | AudioPlayerBackend |
opm.media.audio |
opm.media.audio.config |
| video | VideoPlayerBackend |
opm.media.video |
opm.media.video.config |
| a webview/webpage | WebPlayerBackend |
opm.media.web |
opm.media.web.config |
If your backend is a remote service that should only be tried after local backends fail
(a Chromecast, a Mopidy server), subclass the matching Remote*PlayerBackend instead
(RemoteAudioPlayerBackend, RemoteVideoPlayerBackend, RemoteWebPlayerBackend). They
add no new methods. They only change load order.
Abstract methods¶
from ovos_plugin_manager.templates.media import AudioPlayerBackend
class MyPlayer(AudioPlayerBackend):
def __init__(self, config=None, bus=None):
super().__init__(config, bus)
self.supports_mime_hints = False # set True if play() can use a mime type hint
def supported_uris(self) -> list:
"""List of supported uri schemes, e.g. ['file', 'http', 'https']."""
def play(self):
"""Start playback of the uri passed to load_track()."""
def stop(self) -> bool:
"""Stop playback. Return True if playback was stopped, else False."""
def pause(self):
"""Pause playback; resume() must continue from this exact position."""
def resume(self):
"""Resume playback after pause()."""
def lower_volume(self):
"""Duck the volume while OVOS is listening or speaking."""
def restore_volume(self):
"""Restore the volume lower_volume() ducked."""
def get_track_length(self) -> int:
"""Duration of the current track, in milliseconds."""
def get_track_position(self) -> int:
"""Current playback position, in milliseconds."""
def set_track_position(self, milliseconds):
"""Seek to an absolute position, in milliseconds."""
load_track(uri, metadata=None), seek_forward(seconds), seek_backward(seconds),
track_info(), and shutdown() are implemented by the base class and normally left
alone. load_track only records the URI and metadata and emits LOADED_MEDIA. It does
not start playback. Playback itself is driven by ocp_start() / ocp_pause() /
ocp_resume() / ocp_stop(), which the base class also implements — each emits a
different subset of the ovos.common_play.player.state / .media.state / .track.state
bus events before calling your plain method:
| Wrapper | Emits | Then calls |
|---|---|---|
ocp_start() |
player.state + media.state |
play() |
ocp_pause() |
player.state only |
pause() |
ocp_resume() |
player.state + track.state |
resume() |
ocp_stop() |
player.state + media.state |
stop() |
ocp_pause(), ocp_resume(), and ocp_stop() are no-ops (they neither emit nor call your
method) when no track is currently loaded. Implement the plain methods, not the ocp_*
wrappers.
Two constructor arguments matter: config (your plugin's settings dict) and bus (the
MessageBusClient OCP passes in). bus falls back to a FakeBus() when unset, which is
what makes the plugin importable and testable outside a running OVOS stack.
Minimal complete example¶
Project layout:
ovos_media_plugin_noop/__init__.py: an audio backend wrapping a hypothetical
myplayer library with play(uri), stop(), pause(), resume(), position_ms(),
seek(ms), duration_ms(), and set_volume(percent):
from ovos_plugin_manager.templates.media import AudioPlayerBackend
class NoopAudioService(AudioPlayerBackend):
def __init__(self, config=None, bus=None):
super().__init__(config, bus)
self._volume = 100
self._player = None # lazily created in play()
def supported_uris(self) -> list:
return ["file", "http", "https"]
def play(self):
import myplayer
self._player = myplayer.Player()
self._player.play(self._now_playing)
def stop(self) -> bool:
if self._player:
self._player.stop()
self._player = None
return True
return False
def pause(self):
if self._player:
self._player.pause()
def resume(self):
if self._player:
self._player.resume()
def lower_volume(self):
if self._player:
self._player.set_volume(int(self._volume * 0.3))
def restore_volume(self):
if self._player:
self._player.set_volume(self._volume)
def get_track_length(self) -> int:
return self._player.duration_ms() if self._player else 0
def get_track_position(self) -> int:
return self._player.position_ms() if self._player else 0
def set_track_position(self, milliseconds):
if self._player:
self._player.seek(milliseconds)
pyproject.toml. The entry-point group must match the base class:
[project]
name = "ovos-media-plugin-noop"
version = "0.1.0"
dependencies = ["ovos-plugin-manager"]
[project.entry-points."opm.media.audio"]
ovos-media-plugin-noop = "ovos_media_plugin_noop:NoopAudioService"
opm.media.audio.config (or .video/.web) is the parallel, optional group where a
plugin registers a dict of config metadata for installers and GUIs. Add it once the
plugin has settings worth advertising.
Unit-testing without OVOS¶
The base class defaults bus to a FakeBus(), so a backend is testable with no
messagebus and no running OVOS stack:
from ovos_media_plugin_noop import NoopAudioService
backend = NoopAudioService()
assert "https" in backend.supported_uris()
backend.load_track("https://example.com/track.mp3")
assert backend._now_playing == "https://example.com/track.mp3"
Exercise ocp_start() / ocp_pause() / ocp_resume() / ocp_stop() too, not just the
plain play()/pause()/resume()/stop() methods. OCP calls the ocp_* wrappers,
and that is also where you can assert on the bus events a FakeBus recorded.
Verify discovery¶
After pip install -e .:
from ovos_plugin_manager.ocp import find_ocp_audio_plugins
print(find_ocp_audio_plugins())
# {'ovos-media-plugin-noop': <class 'ovos_media_plugin_noop.NoopAudioService'>}
find_ocp_video_plugins() and find_ocp_web_plugins() are the matching lookups for the
other two groups (all three live in ovos_plugin_manager.ocp).
Publish checklist¶
- The class subclasses
AudioPlayerBackend,VideoPlayerBackend, orWebPlayerBackend(or theirRemote*variant) and implements all ten abstract methods with the documented signatures and return types:supported_uris,play,stop,pause,resume,lower_volume,restore_volume,get_track_length,get_track_position,set_track_position.MediaBackendusesABCMeta, so leaving any one of them out is aTypeErrorthe first time the plugin is instantiated, not a runtime surprise later. - The entry-point group in
pyproject.tomlmatches that base class (opm.media.audio/.video/.web). __init__acceptsconfig=None, bus=Noneand works with an empty config and aFakeBus().play()/pause()/resume()/stop()operate onself._now_playing, set by the base class'sload_track(). Do not require callers to pass the URI again.- Unit tests exercise the backend directly, with no OVOS services running.
find_ocp_audio_plugins()(or the_video/_webequivalent) discovers the installed plugin under the expected name.- If shipping a dual old-audio-service entry point too, keep the
opm.media.*class and themycroft.plugin.audioserviceclass separate. They are different contracts, not the same class registered twice.
Read next: Media Plugins Related: OCP Audio Plugin · Media Service (ovos-media) · Plugin Manager