from typing import Dict, Iterable, List, Optional, Union from platypush.plugins import RunnablePlugin, action from platypush.plugins.media import PlayerState from platypush.schemas.mopidy import ( MopidyAlbumSchema, MopidyArtistSchema, MopidyDirectorySchema, MopidyFilterSchema, MopidyPlaylistSchema, MopidyStatusSchema, MopidyTrackSchema, ) from platypush.utils import wait_for_either from ._client import MopidyClient from ._common import DEFAULT_TIMEOUT from ._conf import MopidyConfig from ._exc import EmptyTrackException from ._playlist import MopidyPlaylist from ._status import MopidyStatus from ._sync import PlaylistSync from ._task import MopidyTask from ._track import MopidyTrack class MusicMopidyPlugin(RunnablePlugin): """ This plugin allows you to track the events from a Mopidy instance and control it through the Mopidy HTTP API. Requires: * A Mopidy instance running with the HTTP service enabled. """ def __init__( self, host: str = 'localhost', port: int = 6680, ssl: bool = False, timeout: Optional[float] = DEFAULT_TIMEOUT, **kwargs, ): """ :param host: Mopidy host (default: localhost). :param port: Mopidy HTTP port (default: 6680). :param ssl: Set to True if the Mopidy server is running on HTTPS. :param timeout: Default timeout for the Mopidy requests (default: 20s). """ super().__init__(**kwargs) self.config = MopidyConfig(host=host, port=port, ssl=ssl, timeout=timeout) self._status = MopidyStatus() self._tasks: Dict[int, MopidyTask] = {} self._client: Optional[MopidyClient] = None self._playlist_sync = PlaylistSync() def _exec(self, *msgs: dict, **kwargs): assert self._client, "Mopidy client not running" return self._client.exec( *msgs, timeout=kwargs.pop('timeout', self.config.timeout) ) def _exec_with_status(self, *msgs: dict, **kwargs): self._exec(*msgs, **kwargs) return self._dump_status() def _dump_status(self): assert self._client, "Mopidy client not running" return MopidyStatusSchema().dump(self._client.status) def _dump_results(self, results: List[dict]) -> List[dict]: schema_by_type = { 'artist': MopidyArtistSchema(), 'album': MopidyAlbumSchema(), 'directory': MopidyDirectorySchema(), 'playlist': MopidyPlaylistSchema(), 'track': MopidyTrackSchema(), } return [ { **( MopidyTrack.parse(item).to_dict() # type: ignore if item['type'] == 'track' else schema_by_type[item['type']].dump(item) ), 'type': item['type'], } for item in results ] def _dump_search_results(self, results: List[dict]) -> List[dict]: return self._dump_results( [ { **item, 'type': res_type, } for search_provider in results for res_type in ['artist', 'album', 'track'] for item in search_provider.get(res_type + 's', []) ] ) def _lookup(self, *uris: str) -> Dict[str, List[MopidyTrack]]: if not uris: return {} if len(uris) > 1: # If more than one URI is specified, we need to call only # library.lookup, as playlist.lookup does not support multiple # URIs. result = self._exec( {'method': 'core.library.lookup', 'uris': uris}, )[0] else: # Otherwise, search both in the library and in the playlist # controllers. uri = uris[0] result = self._exec( {'method': 'core.playlists.lookup', 'uri': uri}, {'method': 'core.library.lookup', 'uris': [uri]}, ) result = { uri: ( result[0].get('tracks', []) if result[0] else list(result[1].values())[0] ) } ret = {} for uri, tracks in result.items(): ret[uri] = [] for track in tracks: parsed_track = MopidyTrack.parse(track) if parsed_track: ret[uri].append(parsed_track) return ret def _add( self, *resources: str, position: Optional[int] = None, clear: bool = False, lookup: bool = True, ): batch_size = 50 results = self._lookup(*resources).values() ret = [] uris = ( [track.uri for tracks in results for track in tracks if track and track.uri] if lookup else list(resources) ) with self._playlist_sync: if clear: self.clear() for i in range(0, len(uris), batch_size): ret += self._exec( { 'method': 'core.tracklist.add', 'uris': uris[i : i + batch_size], 'at_position': position, } )[0] self.logger.info('Loaded %d/%d tracks', len(ret), len(uris)) return ret def _get_playlist(self, playlist: str, with_tracks: bool = False) -> MopidyPlaylist: playlists = self._get_playlists() pl_by_name = {p.name: p for p in playlists} pl_by_uri = {p.uri: p for p in playlists} pl = pl_by_uri.get(playlist, pl_by_name.get(playlist)) assert pl, f"Playlist {playlist} not found" if with_tracks: pl.tracks = self._get_playlist_tracks(playlist) return pl def _get_playlist_tracks(self, playlist: str) -> List[MopidyTrack]: playlists = self._get_playlists() pl_by_name = {p.name: p for p in playlists} pl_by_uri = {p.uri: p for p in playlists} pl = pl_by_uri.get(playlist, pl_by_name.get(playlist)) assert pl, f"Playlist {playlist} not found" tracks = self._exec({'method': 'core.playlists.get_items', 'uri': pl.uri})[0] assert tracks is not None, f"Playlist {playlist} not found" ret = [] for track in tracks: parsed_track = MopidyTrack.parse(track) if parsed_track: ret.append(parsed_track) return ret def _get_playlists(self, **__) -> List[MopidyPlaylist]: return [ MopidyPlaylist.parse(pl) for pl in self._exec({'method': 'core.playlists.as_list'})[0] ] def _save_playlist(self, playlist: MopidyPlaylist): return self._exec( { 'method': 'core.playlists.save', 'playlist': { '__model__': 'Playlist', 'uri': playlist.uri, 'name': playlist.name, 'tracks': [ { '__model__': 'Track', 'uri': track.uri, } for track in playlist.tracks ], }, } )[0] @action def play( self, resource: Optional[str] = None, position: Optional[int] = None, track_id: Optional[int] = None, **__, ): """ Start playback, or play a resource by URI. :param resource: Resource path/URI. If not specified, it will resume the playback if paused/stopped, otherwise it will start playing the selected track. :param track_id: The ID of track (or ``tlid``) in the current playlist that should be played, if you want to play a specific track already loaded in the current playlist. :param position: Position number (0-based) of the track in the current playlist that should be played. :return: .. schema:: mopidy.MopidyStatusSchema """ if resource: ret = self._add(resource, position=0) if not ret: self.logger.warning('Failed to add %s to the tracklist', resource) elif isinstance(ret, dict): track_id = ret.get('tlid') elif position is not None: tracklist = self._exec({'method': 'core.tracklist.get_tl_tracks'})[0] if position < 0 or position >= len(tracklist): self.logger.warning( 'Position %d is out of bounds for the current tracklist', position ) return None track_id = tracklist[position]['tlid'] return self._exec_with_status( {'method': 'core.playback.play', 'tlid': track_id} ) @action def play_pos(self, pos: int): """ Play a track in the current playlist by position number. Legacy alias for :meth:`.play` with a ``position`` parameter. :param pos: Position number (0-based). """ return self.play(position=pos) @action def load(self, playlist: str, play: bool = True): """ Load/play a playlist. This method will clear the current playlist and load the tracks from the given playlist. You should usually prefer :meth:`.add` to this method, as it is more general-purpose (``load`` only works with playlists). This method exists mainly for compatibility with the MPD plugin. :param playlist: Playlist URI. :param play: Start playback after loading the playlist (default: True). """ self._add(playlist, clear=True) if play: self.play() @action def lookup(self, resources: Iterable[str], **__): """ Lookup (one or) resources by URI. Given a list of URIs, this method will return a dictionary in the form ``{uri: [track1, track2, ...]}``. :param resource: Resource URI(s). :return: .. schema:: mopidy.MopidyTrackSchema(many=True) """ return { uri: [track.to_dict() for track in tracks] for uri, tracks in self._lookup(*resources).items() } @action def pause(self, **__): """ Pause the playback. :return: .. schema:: mopidy.MopidyStatusSchema """ return self._exec_with_status({'method': 'core.playback.pause'}) @action def stop(self, **__): # type: ignore """ Stop the playback. :return: .. schema:: mopidy.MopidyStatusSchema """ return self._exec_with_status({'method': 'core.playback.stop'}) @action def prev(self, **__): """ Play the previous track. :return: .. schema:: mopidy.MopidyStatusSchema """ return self._exec_with_status({'method': 'core.playback.previous'}) @action def next(self, **__): """ Play the next track. :return: .. schema:: mopidy.MopidyStatusSchema """ return self._exec_with_status({'method': 'core.playback.next'}) @action def add( self, resource: Union[str, Iterable[str]], *_, position: Optional[int] = None, **__, ): """ Add a resource (track, album, artist, folder etc.) to the current playlist. :param resource: Resource URI(s). :param position: Position (0-based) where the track(s) will be inserted (default: end of the playlist). :return: The list of tracks added to the queue. .. schema:: mopidy.MopidyTrackSchema(many=True) """ resources = [resource] if isinstance(resource, str) else resource tracks = [ MopidyTrack.parse(track) for track in self._add(*resources, position=position) ] return [track.to_dict() for track in tracks if track] @action def pause_if_playing(self, **__): """ Pause the playback if it's currently playing. :return: .. schema:: mopidy.MopidyStatusSchema """ if self._status.state == PlayerState.PLAY: return self.pause() return self._dump_status() @action def play_if_paused(self, **__): """ Resume the playback if it's currently paused. :return: .. schema:: mopidy.MopidyStatusSchema """ if self._status.state == PlayerState.PAUSE: return self.play() return self._dump_status() @action def play_if_paused_or_stopped(self): """ Resume the playback if it's currently paused or stopped. :return: .. schema:: mopidy.MopidyStatusSchema """ if self._status.state in {PlayerState.PAUSE, PlayerState.STOP}: return self.play() return self._dump_status() @action def play_or_stop(self): """ Play if the playback is stopped, stop if it's playing, otherwise resume playback. :return: .. schema:: mopidy.MopidyStatusSchema """ if self._status.state == PlayerState.PLAY: return self.stop() return self.play() @action def set_volume(self, volume: int, **__): """ Set the volume. :param volume: Volume level (0-100). :return: .. schema:: mopidy.MopidyStatusSchema """ return self._exec_with_status( {'method': 'core.mixer.set_volume', 'volume': volume} ) @action def volup(self, step: int = 5, **__): """ Increase the volume by a given step. :param step: Volume step (default: 5%). :return: .. schema:: mopidy.MopidyStatusSchema """ return self.set_volume(volume=min(100, self._status.volume + step)) @action def voldown(self, step: int = 5, **__): """ Decrease the volume by a given step. :param step: Volume step (default: 5%). :return: .. schema:: mopidy.MopidyStatusSchema """ return self.set_volume(volume=max(0, self._status.volume - step)) @action def random(self, value: Optional[bool] = None, **__): """ Set the random mode. :param value: Random mode. If not specified, it will toggle the current random mode. :return: .. schema:: mopidy.MopidyStatusSchema """ if value is None: value = not self._status.random return self._exec_with_status( {'method': 'core.tracklist.set_random', 'value': bool(value)} ) @action def repeat(self, value: Optional[bool] = None, **__): """ Set the repeat mode. :param value: Repeat mode. If not specified, it will toggle the current repeat mode. :return: .. schema:: mopidy.MopidyStatusSchema """ if value is None: value = not self._status.repeat return self._exec_with_status( {'method': 'core.tracklist.set_repeat', 'value': bool(value)} ) @action def consume(self, value: Optional[bool] = None, **__): """ Set the consume mode. :param value: Consume mode. If not specified, it will toggle the current consume mode. :return: .. schema:: mopidy.MopidyStatusSchema """ if value is None: value = not self._status.consume return self._exec_with_status( {'method': 'core.tracklist.set_consume', 'value': bool(value)} ) @action def single(self, value: Optional[bool] = None, **__): """ Set the single mode. :param value: Single mode. If not specified, it will toggle the current single mode. :return: .. schema:: mopidy.MopidyStatusSchema """ if value is None: value = not self._status.single return self._exec_with_status( {'method': 'core.tracklist.set_single', 'value': bool(value)} ) @action def shuffle(self, **__): """ Shuffle the current playlist. :return: .. schema:: mopidy.MopidyStatusSchema """ return self._exec_with_status({'method': 'core.tracklist.shuffle'}) @action def save(self, name: str, **__): """ Save the current tracklist to a new playlist with the given name. :param name: New playlist name. """ return self._exec({'method': 'core.playlists.save', 'name': name})[0] @action def delete( self, positions: Optional[Iterable[int]] = None, uris: Optional[Iterable[str]] = None, ): """ Delete tracks from the current tracklist. .. note:: At least one of the ``positions`` or ``uris`` parameters must be specified. :param positions: (0-based) positions of the tracks to be deleted. :param uris: URIs of the tracks to be deleted. """ assert ( positions or uris ), "At least one of 'positions' or 'uris' must be specified" criteria = {} if positions: assert self._client, "Mopidy client not running" positions = set(positions) criteria['tlid'] = list( { track.track_id for i, track in enumerate(self._client.tracks) if i in positions } ) if uris: criteria['uri'] = list(uris) return self._exec( { 'method': 'core.tracklist.remove', 'criteria': criteria, } )[0] @action def move( self, start: Optional[int] = None, end: Optional[int] = None, position: Optional[int] = None, from_pos: Optional[int] = None, to_pos: Optional[int] = None, **__, ): """ Move one or more tracks in the current playlist to a new position. You can pass either: - ``start``, ``end`` and ``position`` to move a slice of tracks from ``start`` to ``end`` to the new position ``position``. - ``from_pos`` and ``to_pos`` to move a single track from ``from_pos`` to ``to_pos``. .. note: Positions are 0-based (i.e. the first track has position 0). :param start: Start position of the slice of tracks to be moved. :param end: End position of the slice of tracks to be moved. :param position: New position where the tracks will be inserted. :param from_pos: Alias for ``start`` - it only works with one track at the time. Maintained for compatibility with :meth:`platypush.plugins.music.mpd.MusicMpdPlugin.move`. :param to_pos: Alias for ``position`` - it only works with one track at the time. Maintained for compatibility with :meth:`platypush.plugins.music.mpd.MusicMpdPlugin.move`. """ assert (from_pos is not None and to_pos is not None) or ( start is not None and end is not None and position is not None ), 'Either "start", "end" and "position", or "from_pos" and "to_pos" must be specified' if (from_pos is not None) and (to_pos is not None): start, end, position = from_pos, from_pos, to_pos ret = self._exec( { 'method': 'core.tracklist.move', 'start': start, 'end': end, 'to_position': position, } )[0] if self._client: self._client.refresh_status(with_tracks=True) return ret @action def clear(self, **__): """ Clear the current playlist. """ self._exec_with_status({'method': 'core.tracklist.clear'}) @action def seek(self, position: float, **__): """ Seek to a given position in the current track. :param position: Position in seconds. :return: .. schema:: mopidy.MopidyStatusSchema """ return self._exec_with_status( {'method': 'core.playback.seek', 'time_position': int(position * 1000)} ) @action def back(self, delta: float = 10, **__): """ Seek back by a given number of seconds. :param delta: Number of seconds to seek back (default: 10s). :return: .. schema:: mopidy.MopidyStatusSchema """ if self._status.playing_pos is None: return self._dump_status() return self.seek(position=self._status.playing_pos - delta) @action def forward(self, delta: float = 10, **__): """ Seek forward by a given number of seconds. :param delta: Number of seconds to seek forward (default: 10s). :return: .. schema:: mopidy.MopidyStatusSchema """ if self._status.playing_pos is None: return self._dump_status() return self.seek(position=self._status.playing_pos + delta) @action def status(self, **__): """ Get the current Mopidy status. :return: .. schema:: mopidy.MopidyStatusSchema """ assert self._client, "Mopidy client not running" self._client.refresh_status() return self._dump_status() @action def current_track(self, **__): """ Get the current track. :return: .. schema:: mopidy.MopidyTrackSchema """ assert self._client, "Mopidy client not running" if not self._client.status.track: return None return self._client.status.track.to_dict() @action def get_tracks(self, **__): """ Get the current playlist tracks. :return: .. schema:: mopidy.MopidyTrackSchema(many=True) """ assert self._client, "Mopidy client not running" return [t.to_dict() for t in self._client.tracks] @action def get_playlists(self, **__): """ Get the available playlists. :return: .. schema:: mopidy.MopidyPlaylistSchema(many=True) """ return MopidyPlaylistSchema().dump(self._get_playlists(), many=True) @action def get_playlist(self, playlist: str, **__): """ Get the items in a playlist. :param playlist: Playlist URI. :param only_tracks: If True, only the tracks will be returned, otherwise the full playlist object will be returned - including name and other metadata. :return: .. schema:: mopidy.MopidyTrackSchema(many=True) """ tracks = self._get_playlist_tracks(playlist) tracks_by_uri = {t.uri: t for t in tracks if t.uri} looked_up = self._lookup(*tracks_by_uri.keys()) return [ track.to_dict() for track in [ (looked_up[uri][0] if looked_up.get(uri) else track) for uri, track in tracks_by_uri.items() ] ] @action def get_playlist_uri_schemes(self, **__): """ Get the available playlist URI schemes. :return: List of available playlist URI schemes. """ return self._exec({'method': 'core.playlists.get_uri_schemes'})[0] @action def create_playlist(self, name: str, uri_scheme: str = 'm3u', **__): """ Create a new playlist. :param name: Playlist name. :param uri_scheme: URI scheme for the playlist (default: ``m3u``). You can get a full list of the available URI schemes that support playlist creation on the Mopidy instance by calling :meth:`.get_playlist_uri_schemes`. :return: .. schema:: mopidy.MopidyPlaylistSchema """ return MopidyPlaylistSchema().dump( self._exec( { 'method': 'core.playlists.create', 'name': name, 'uri_scheme': uri_scheme, } )[0] ) @action def delete_playlist(self, playlist: str, **__): """ Delete a playlist. :param playlist: Playlist URI. :return: ``True`` if the playlist was deleted, ``False`` otherwise. """ return self._exec({'method': 'core.playlists.delete', 'uri': playlist})[0] @action def add_to_playlist( self, playlist: str, resources: Iterable[str], position: Optional[int] = None, allow_duplicates: bool = False, **__, ): """ Add tracks to a playlist. :param playlist: Playlist URI/name. :param resources: List of track URIs. :param position: Position where the tracks will be inserted (default: end of the playlist). :param allow_duplicates: If True, the tracks will be added even if they are already present in the playlist (default: False). :return: The modified playlist. .. schema:: mopidy.MopidyPlaylistSchema """ pl = self._get_playlist(playlist, with_tracks=True) if not allow_duplicates: existing_uris = {t.uri for t in pl.tracks} resources = [t for t in resources if t not in existing_uris] new_tracks = [MopidyTrack(uri=t) for t in resources] if position is not None: pl.tracks = pl.tracks[:position] + new_tracks + pl.tracks[position:] else: pl.tracks += new_tracks self._save_playlist(pl) return pl.to_dict() @action def remove_from_playlist( self, playlist: str, resources: Optional[Iterable[Union[str, int]]] = None, from_pos: Optional[int] = None, to_pos: Optional[int] = None, **__, ): """ Remove tracks from a playlist. This action can work in three different ways: - If the ``resources`` parameter is specified, and it contains strings, it will remove the tracks matching the provided URIs. - If the ``resources`` parameter is specified, and it contains integers, it will remove the tracks in the specified positions. - If the ``from_pos`` and ``to_pos`` parameters are specified, it will remove the tracks in the specified range (inclusive). .. note: Positions are 0-based (i.e. the first track has position 0). :param playlist: Playlist URI/name. :param tracks: List of track URIs. :param from_pos: Start position of the slice of tracks to be removed. :param to_pos: End position of the slice of tracks to be removed. :return: The modified playlist. .. schema:: mopidy.MopidyPlaylistSchema """ assert resources or ( from_pos is not None and to_pos is not None ), "Either 'tracks', or 'positions', or 'from_pos' and 'to_pos' must be specified" pl = self._get_playlist(playlist, with_tracks=True) if resources: resources = set(resources) positions = { i for i, t in enumerate(pl.tracks) if t.uri in resources or i in resources } pl.tracks = [t for i, t in enumerate(pl.tracks) if i not in positions] elif from_pos is not None and to_pos is not None: from_pos, to_pos = (min(from_pos, to_pos), max(from_pos, to_pos)) pl.tracks = pl.tracks[: from_pos - 1] + pl.tracks[to_pos:] self._save_playlist(pl) return pl.to_dict() @action def playlist_move( self, playlist: str, start: Optional[int] = None, end: Optional[int] = None, position: Optional[int] = None, from_pos: Optional[int] = None, to_pos: Optional[int] = None, **__, ): """ Move tracks in a playlist. This action can work in two different ways: - If the ``start``, ``end`` and ``position`` parameters are specified, it will move an individual track from the position ``start`` to the position ``end`` to the new position ``position``. - If the ``from_pos``, ``to_pos`` and ``position`` parameters are specified, it will move the tracks in the specified range (inclusive) to the new position ``position``. .. note: Positions are 0-based (i.e. the first track has position 0). :param playlist: Playlist URI. :param start: Start position of the slice of tracks to be moved. :param end: End position of the slice of tracks to be moved. :param position: New position where the tracks will be inserted. :return: The modified playlist. .. schema:: mopidy.MopidyPlaylistSchema """ assert (start is not None and end is not None and position is not None) or ( from_pos is not None and to_pos is not None ), "Either 'start', 'end' and 'position', or 'from_pos' and 'to_pos' must be specified" pl = self._get_playlist(playlist, with_tracks=True) if from_pos is not None and to_pos is not None: from_pos, to_pos = (min(from_pos, to_pos), max(from_pos, to_pos)) pl.tracks = ( pl.tracks[:from_pos] + pl.tracks[to_pos : to_pos + 1] + pl.tracks[from_pos + 1 : to_pos] + pl.tracks[from_pos : from_pos + 1] + pl.tracks[to_pos + 1 :] ) elif start is not None and end is not None and position is not None: start, end = (min(start, end), max(start, end)) if start == end: end += 1 if start < position: pl.tracks = ( pl.tracks[:start] + pl.tracks[end : end + (position - start)] + pl.tracks[start:end] + pl.tracks[end + (position - start) :] ) else: pl.tracks = ( pl.tracks[:position] + pl.tracks[start:end] + pl.tracks[position:start] + pl.tracks[end:] ) self._save_playlist(pl) return pl.to_dict() @action def playlist_clear(self, playlist: str, **__): """ Remove all the tracks from a playlist. :param playlist: Playlist URI/name. :return: The modified playlist. .. schema:: mopidy.MopidyPlaylistSchema """ pl = self._get_playlist(playlist) pl.tracks = [] self._save_playlist(pl) return pl.to_dict() @action def rename_playlist(self, playlist: str, new_name: str, **__): """ Rename a playlist. :param playlist: Playlist URI/name. :param new_name: New playlist name. :return: The modified playlist. .. schema:: mopidy.MopidyPlaylistSchema """ pl = self._get_playlist(playlist, with_tracks=True) pl.name = new_name self._save_playlist(pl) return pl.to_dict() @action def get_images(self, resources: Iterable[str], **__) -> Dict[str, Optional[str]]: """ Get the images for a list of URIs. :param resources: List of URIs. :return: Dictionary in the form ``{uri: image_url}``. """ return { uri: next(iter(images or []), {}).get('uri') for uri, images in self._exec( {'method': 'core.library.get_images', 'uris': list(resources)} )[0].items() } @action def search( # pylint: disable=redefined-builtin self, filter: dict, exact: bool = False, **__ ): """ Search items that match the given query. :param filter: .. schema:: mopidy.MopidyFilterSchema :param exact: If True, the search will only return exact matches. :return: A list of result, including: - Tracks .. schema:: mopidy.MopidyTrackSchema(many=True) - Albums .. schema:: mopidy.MopidyAlbumSchema(many=True) - Artists .. schema:: mopidy.MopidyArtistSchema(many=True) """ filter = dict(MopidyFilterSchema().load(filter) or {}) uris = filter.pop('uris', None) kwargs = { 'exact': exact, 'query': filter, **({'uris': uris} if uris else {}), } return self._dump_search_results( self._exec({'method': 'core.library.search', **kwargs})[0] ) @action def find( # pylint: disable=redefined-builtin self, filter: dict, exact: bool = False, **__ ): """ Alias for :meth:`search`, for MPD compatibility. :param filter: .. schema:: mopidy.MopidyFilterSchema :param exact: If True, the search will only return exact matches. :return: .. schema:: mopidy.MopidyTrackSchema(many=True) """ return self.search(filter=filter, exact=exact) @action def browse(self, uri: Optional[str] = None): """ Browse the items under the specified URI. :param uri: URI to browse (default: root directory). :return: A list of result under the specified resource, including: - Directories .. schema:: mopidy.MopidyDirectorySchema(many=True) - Tracks .. schema:: mopidy.MopidyTrackSchema(many=True) - Albums .. schema:: mopidy.MopidyAlbumSchema(many=True) - Artists .. schema:: mopidy.MopidyArtistSchema(many=True) """ return self._dump_results( self._exec({'method': 'core.library.browse', 'uri': uri})[0] ) def main(self): while not self.should_stop(): try: with MopidyClient( config=self.config, status=self._status, stop_event=self._should_stop, playlist_sync=self._playlist_sync, tasks=self._tasks, ) as self._client: self._client.start() wait_for_either(self._should_stop, self._client.closed_event) finally: self._client = None self.wait_stop(10) __all__ = ['EmptyTrackException', 'MusicMopidyPlugin', 'MopidyStatus', 'MopidyTrack'] # vim:sw=4:ts=4:et: