Merge branch 'develop' into feature/http-frontend
This commit is contained in:
commit
7e3fba0155
@ -1,48 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
#! /usr/bin/env python
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.utils.log import setup_console_logging, setup_root_logger
|
||||
from mopidy.scanner import Scanner, translator
|
||||
from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format
|
||||
|
||||
|
||||
setup_root_logger()
|
||||
setup_console_logging(2)
|
||||
|
||||
|
||||
tracks = []
|
||||
|
||||
|
||||
def store(data):
|
||||
track = translator(data)
|
||||
tracks.append(track)
|
||||
logging.debug('Added %s', track.uri)
|
||||
|
||||
|
||||
def debug(uri, error, debug):
|
||||
logging.error('Failed %s: %s - %s', uri, error, debug)
|
||||
|
||||
|
||||
logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH)
|
||||
|
||||
|
||||
scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug)
|
||||
try:
|
||||
scanner.start()
|
||||
except KeyboardInterrupt:
|
||||
scanner.stop()
|
||||
|
||||
|
||||
logging.info('Done')
|
||||
|
||||
|
||||
for a in tracks_to_tag_cache_format(tracks):
|
||||
if len(a) == 1:
|
||||
print ('%s' % a).encode('utf-8')
|
||||
else:
|
||||
print ('%s: %s' % a).encode('utf-8')
|
||||
if __name__ == '__main__':
|
||||
from mopidy.scanner import main
|
||||
main()
|
||||
|
||||
@ -33,6 +33,13 @@ Library provider
|
||||
:members:
|
||||
|
||||
|
||||
Backend listener
|
||||
================
|
||||
|
||||
.. autoclass:: mopidy.backends.listener.BackendListener
|
||||
:members:
|
||||
|
||||
|
||||
.. _backend-implementations:
|
||||
|
||||
Backend implementations
|
||||
|
||||
@ -56,23 +56,29 @@ backends:
|
||||
dummy/mocked lower layers easier than with the old variant, where
|
||||
dependencies where looked up in Pykka's actor registry.
|
||||
|
||||
- The stored playlists part of the core API has been revised to be more focused
|
||||
around the playlist URI, and some redundant functionality has been removed:
|
||||
- Renamed "current playlist" to "tracklist" everywhere, including the core API
|
||||
used by frontends.
|
||||
|
||||
- :attr:`mopidy.core.StoredPlaylistsController.playlists` no longer supports
|
||||
- Renamed "stored playlists" to "playlists" everywhere, including the core API
|
||||
used by frontends.
|
||||
|
||||
- The playlists part of the core API has been revised to be more focused around
|
||||
the playlist URI, and some redundant functionality has been removed:
|
||||
|
||||
- :attr:`mopidy.core.PlaylistsController.playlists` no longer supports
|
||||
assignment to it. The `playlists` property on the backend layer still does,
|
||||
and all functionality is maintained by assigning to the playlists
|
||||
collections at the backend level.
|
||||
|
||||
- :meth:`mopidy.core.StoredPlaylistsController.delete` now accepts an URI,
|
||||
and not a playlist object.
|
||||
- :meth:`mopidy.core.PlaylistsController.delete` now accepts an URI, and not
|
||||
a playlist object.
|
||||
|
||||
- :meth:`mopidy.core.StoredPlaylistsController.save` now returns the saved
|
||||
- :meth:`mopidy.core.PlaylistsController.save` now returns the saved
|
||||
playlist. The returned playlist may differ from the saved playlist, and
|
||||
should thus be used instead of the playlist passed to ``save()``.
|
||||
|
||||
- :meth:`mopidy.core.StoredPlaylistsController.rename` has been removed,
|
||||
since renaming can be done with ``save()``.
|
||||
- :meth:`mopidy.core.PlaylistsController.rename` has been removed, since
|
||||
renaming can be done with ``save()``.
|
||||
|
||||
**Changes**
|
||||
|
||||
@ -105,11 +111,22 @@ backends:
|
||||
- The Spotify backend now returns the track if you search for the Spotify track
|
||||
URI. (Fixes: :issue:`233`)
|
||||
|
||||
- Renamed "current playlist" to "tracklist" everywhere, including the core API
|
||||
used by frontends.
|
||||
- :meth:`mopidy.core.TracklistController.append` now returns a list of the
|
||||
:class:`mopidy.models.TlTrack` instances that was added to the tracklist.
|
||||
This makes it easier to start playing one of the tracks that was just
|
||||
appended to the tracklist.
|
||||
|
||||
- Renamed "stored playlists" to "playlists" everywhere, including the core API
|
||||
used by frontends.
|
||||
- When the tracklist is changed, we now trigger the new
|
||||
:meth:`mopidy.core.CoreListener.tracklist_changed` event. Previously we
|
||||
triggered :meth:`mopidy.core.CoreListener.playlist_changed`, which is
|
||||
intended for stored playlists, not the tracklist.
|
||||
|
||||
- The event :meth:`mopidy.core.CoreListener.playlist_changed` has been changed
|
||||
to include the playlist that was changed.
|
||||
|
||||
- The MPRIS playlists interface is now supported by our MPRIS frontend. This
|
||||
means that you now can select playlists to queue and play from the Ubuntu
|
||||
Sound Menu.
|
||||
|
||||
**Bug fixes**
|
||||
|
||||
@ -122,6 +139,10 @@ backends:
|
||||
- MPD no longer lowercases search queries. This broke e.g. search by URI, where
|
||||
casing may be essential.
|
||||
|
||||
- :issue:`236`: The ``mopidy-scan`` command failed to include tags from ALAC
|
||||
files (Apple lossless) because it didn't support multiple tag messages from
|
||||
GStreamer per track it scanned.
|
||||
|
||||
|
||||
v0.8.1 (2012-10-30)
|
||||
===================
|
||||
|
||||
@ -9,8 +9,8 @@ Specification. It's a spec that describes a standard D-Bus interface for making
|
||||
media players available to other applications on the same system.
|
||||
|
||||
Mopidy's :ref:`MPRIS frontend <mpris-frontend>` currently implements all
|
||||
required parts of the MPRIS spec, but not the optional playlist interface. For
|
||||
tracking the development of the playlist interface, see :issue:`229`.
|
||||
required parts of the MPRIS spec, plus the optional playlist interface. It does
|
||||
not implement the optional tracklist interface.
|
||||
|
||||
|
||||
.. _ubuntu-sound-menu:
|
||||
|
||||
@ -3,3 +3,4 @@ from __future__ import unicode_literals
|
||||
# flake8: noqa
|
||||
from .actor import Audio
|
||||
from .listener import AudioListener
|
||||
from .constants import PlaybackState
|
||||
|
||||
@ -13,6 +13,7 @@ from mopidy import settings
|
||||
from mopidy.utils import process
|
||||
|
||||
from . import mixers
|
||||
from .constants import PlaybackState
|
||||
from .listener import AudioListener
|
||||
|
||||
logger = logging.getLogger('mopidy.audio')
|
||||
@ -29,9 +30,11 @@ class Audio(pykka.ThreadingActor):
|
||||
- :attr:`mopidy.settings.OUTPUT`
|
||||
- :attr:`mopidy.settings.MIXER`
|
||||
- :attr:`mopidy.settings.MIXER_TRACK`
|
||||
|
||||
"""
|
||||
|
||||
#: The GStreamer state mapped to :class:`mopidy.audio.PlaybackState`
|
||||
state = PlaybackState.STOPPED
|
||||
|
||||
def __init__(self):
|
||||
super(Audio, self).__init__()
|
||||
|
||||
@ -39,8 +42,11 @@ class Audio(pykka.ThreadingActor):
|
||||
self._mixer = None
|
||||
self._mixer_track = None
|
||||
self._software_mixing = False
|
||||
self._appsrc = None
|
||||
|
||||
self._message_processor_set_up = False
|
||||
self._notify_source_signal_id = None
|
||||
self._about_to_finish_id = None
|
||||
self._message_signal_id = None
|
||||
|
||||
def on_start(self):
|
||||
try:
|
||||
@ -63,7 +69,13 @@ class Audio(pykka.ThreadingActor):
|
||||
fakesink = gst.element_factory_make('fakesink')
|
||||
self._playbin.set_property('video-sink', fakesink)
|
||||
|
||||
self._playbin.connect('notify::source', self._on_new_source)
|
||||
self._about_to_finish_id = self._playbin.connect(
|
||||
'about-to-finish', self._on_about_to_finish)
|
||||
self._notify_source_signal_id = self._playbin.connect(
|
||||
'notify::source', self._on_new_source)
|
||||
|
||||
def _on_about_to_finish(self, element):
|
||||
self._appsrc = None
|
||||
|
||||
def _on_new_source(self, element, pad):
|
||||
uri = element.get_property('uri')
|
||||
@ -77,8 +89,15 @@ class Audio(pykka.ThreadingActor):
|
||||
b'rate=(int)44100')
|
||||
source = element.get_property('source')
|
||||
source.set_property('caps', default_caps)
|
||||
source.set_property('format', b'time') # Gstreamer does not like unicode
|
||||
|
||||
self._appsrc = source
|
||||
|
||||
def _teardown_playbin(self):
|
||||
if self._about_to_finish_id:
|
||||
self._playbin.disconnect(self._about_to_finish_id)
|
||||
if self._notify_source_signal_id:
|
||||
self._playbin.disconnect(self._notify_source_signal_id)
|
||||
self._playbin.set_state(gst.STATE_NULL)
|
||||
|
||||
def _setup_output(self):
|
||||
@ -151,17 +170,21 @@ class Audio(pykka.ThreadingActor):
|
||||
def _setup_message_processor(self):
|
||||
bus = self._playbin.get_bus()
|
||||
bus.add_signal_watch()
|
||||
bus.connect('message', self._on_message)
|
||||
self._message_processor_set_up = True
|
||||
self._message_signal_id = bus.connect('message', self._on_message)
|
||||
|
||||
def _teardown_message_processor(self):
|
||||
if self._message_processor_set_up:
|
||||
if self._message_signal_id:
|
||||
bus = self._playbin.get_bus()
|
||||
bus.disconnect(self._message_signal_id)
|
||||
bus.remove_signal_watch()
|
||||
|
||||
def _on_message(self, bus, message):
|
||||
if message.type == gst.MESSAGE_EOS:
|
||||
self._trigger_reached_end_of_stream_event()
|
||||
if (message.type == gst.MESSAGE_STATE_CHANGED
|
||||
and message.src == self._playbin):
|
||||
old_state, new_state, pending_state = message.parse_state_changed()
|
||||
self._on_playbin_state_changed(old_state, new_state, pending_state)
|
||||
elif message.type == gst.MESSAGE_EOS:
|
||||
self._on_end_of_stream()
|
||||
elif message.type == gst.MESSAGE_ERROR:
|
||||
error, debug = message.parse_error()
|
||||
logger.error('%s %s', error, debug)
|
||||
@ -170,8 +193,37 @@ class Audio(pykka.ThreadingActor):
|
||||
error, debug = message.parse_warning()
|
||||
logger.warning('%s %s', error, debug)
|
||||
|
||||
def _trigger_reached_end_of_stream_event(self):
|
||||
logger.debug('Triggering reached end of stream event')
|
||||
def _on_playbin_state_changed(self, old_state, new_state, pending_state):
|
||||
if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL:
|
||||
# XXX: We're not called on the last state change when going down to
|
||||
# NULL, so we rewrite the second to last call to get the expected
|
||||
# behavior.
|
||||
new_state = gst.STATE_NULL
|
||||
pending_state = gst.STATE_VOID_PENDING
|
||||
|
||||
if pending_state != gst.STATE_VOID_PENDING:
|
||||
return # Ignore intermediate state changes
|
||||
|
||||
if new_state == gst.STATE_READY:
|
||||
return # Ignore READY state as it's GStreamer specific
|
||||
|
||||
if new_state == gst.STATE_PLAYING:
|
||||
new_state = PlaybackState.PLAYING
|
||||
elif new_state == gst.STATE_PAUSED:
|
||||
new_state = PlaybackState.PAUSED
|
||||
elif new_state == gst.STATE_NULL:
|
||||
new_state = PlaybackState.STOPPED
|
||||
|
||||
old_state, self.state = self.state, new_state
|
||||
|
||||
logger.debug(
|
||||
'Triggering event: state_changed(old_state=%s, new_state=%s)',
|
||||
old_state, new_state)
|
||||
AudioListener.send('state_changed',
|
||||
old_state=old_state, new_state=new_state)
|
||||
|
||||
def _on_end_of_stream(self):
|
||||
logger.debug('Triggering reached_end_of_stream event')
|
||||
AudioListener.send('reached_end_of_stream')
|
||||
|
||||
def set_uri(self, uri):
|
||||
@ -185,23 +237,21 @@ class Audio(pykka.ThreadingActor):
|
||||
"""
|
||||
self._playbin.set_property('uri', uri)
|
||||
|
||||
def emit_data(self, capabilities, data):
|
||||
def emit_data(self, buffer_):
|
||||
"""
|
||||
Call this to deliver raw audio data to be played.
|
||||
|
||||
Note that the uri must be set to ``appsrc://`` for this to work.
|
||||
|
||||
:param capabilities: a GStreamer capabilities string
|
||||
:type capabilities: string
|
||||
:param data: raw audio data to be played
|
||||
"""
|
||||
caps = gst.caps_from_string(capabilities)
|
||||
buffer_ = gst.Buffer(buffer(data))
|
||||
buffer_.set_caps(caps)
|
||||
Returns true if data was delivered.
|
||||
|
||||
source = self._playbin.get_property('source')
|
||||
source.set_property('caps', caps)
|
||||
source.emit('push-buffer', buffer_)
|
||||
:param buffer_: buffer to pass to appsrc
|
||||
:type buffer_: :class:`gst.Buffer`
|
||||
:rtype: boolean
|
||||
"""
|
||||
if not self._appsrc:
|
||||
return False
|
||||
return self._appsrc.emit('push-buffer', buffer_) == gst.FLOW_OK
|
||||
|
||||
def emit_end_of_stream(self):
|
||||
"""
|
||||
|
||||
16
mopidy/audio/constants.py
Normal file
16
mopidy/audio/constants.py
Normal file
@ -0,0 +1,16 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
|
||||
class PlaybackState(object):
|
||||
"""
|
||||
Enum of playback states.
|
||||
"""
|
||||
|
||||
#: Constant representing the paused state.
|
||||
PAUSED = 'paused'
|
||||
|
||||
#: Constant representing the playing state.
|
||||
PLAYING = 'playing'
|
||||
|
||||
#: Constant representing the stopped state.
|
||||
STOPPED = 'stopped'
|
||||
@ -28,3 +28,18 @@ class AudioListener(object):
|
||||
*MAY* be implemented by actor.
|
||||
"""
|
||||
pass
|
||||
|
||||
def state_changed(self, old_state, new_state):
|
||||
"""
|
||||
Called after the playback state have changed.
|
||||
|
||||
Will be called for both immediate and async state changes in GStreamer.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param old_state: the state before the change
|
||||
:type old_state: string from :class:`mopidy.core.PlaybackState` field
|
||||
:param new_state: the state after the change
|
||||
:type new_state: string from :class:`mopidy.core.PlaybackState` field
|
||||
"""
|
||||
pass
|
||||
|
||||
@ -82,22 +82,30 @@ class DummyPlaybackProvider(base.BasePlaybackProvider):
|
||||
|
||||
class DummyPlaylistsProvider(base.BasePlaylistsProvider):
|
||||
def create(self, name):
|
||||
playlist = Playlist(name=name)
|
||||
playlist = Playlist(name=name, uri='dummy:%s' % name)
|
||||
self._playlists.append(playlist)
|
||||
return playlist
|
||||
|
||||
def delete(self, playlist):
|
||||
self._playlists.remove(playlist)
|
||||
def delete(self, uri):
|
||||
playlist = self.lookup(uri)
|
||||
if playlist:
|
||||
self._playlists.remove(playlist)
|
||||
|
||||
def lookup(self, uri):
|
||||
return filter(lambda p: p.uri == uri, self._playlists)
|
||||
for playlist in self._playlists:
|
||||
if playlist.uri == uri:
|
||||
return playlist
|
||||
|
||||
def refresh(self):
|
||||
pass
|
||||
|
||||
def rename(self, playlist, new_name):
|
||||
self._playlists[self._playlists.index(playlist)] = \
|
||||
playlist.copy(name=new_name)
|
||||
|
||||
def save(self, playlist):
|
||||
self._playlists.append(playlist)
|
||||
old_playlist = self.lookup(playlist.uri)
|
||||
|
||||
if old_playlist is not None:
|
||||
index = self._playlists.index(old_playlist)
|
||||
self._playlists[index] = playlist
|
||||
else:
|
||||
self._playlists.append(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
32
mopidy/backends/listener.py
Normal file
32
mopidy/backends/listener.py
Normal file
@ -0,0 +1,32 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import pykka
|
||||
|
||||
|
||||
class BackendListener(object):
|
||||
"""
|
||||
Marker interface for recipients of events sent by the backend actors.
|
||||
|
||||
Any Pykka actor that mixes in this class will receive calls to the methods
|
||||
defined here when the corresponding events happen in the core actor. This
|
||||
interface is used both for looking up what actors to notify of the events,
|
||||
and for providing default implementations for those listeners that are not
|
||||
interested in all events.
|
||||
|
||||
Normally, only the Core actor should mix in this class.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def send(event, **kwargs):
|
||||
"""Helper to allow calling of backend listener events"""
|
||||
listeners = pykka.ActorRegistry.get_by_class(BackendListener)
|
||||
for listener in listeners:
|
||||
getattr(listener.proxy(), event)(**kwargs)
|
||||
|
||||
def playlists_loaded(self):
|
||||
"""
|
||||
Called when playlists are loaded or refreshed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
"""
|
||||
pass
|
||||
@ -6,7 +6,7 @@ import os
|
||||
import shutil
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends import base
|
||||
from mopidy.backends import base, listener
|
||||
from mopidy.models import Playlist
|
||||
from mopidy.utils import formatting, path
|
||||
|
||||
@ -63,6 +63,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider):
|
||||
playlists.append(playlist)
|
||||
|
||||
self.playlists = playlists
|
||||
listener.BackendListener.send('playlists_loaded')
|
||||
|
||||
def save(self, playlist):
|
||||
assert playlist.uri, 'Cannot save playlist without URI'
|
||||
|
||||
@ -52,12 +52,8 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider):
|
||||
return self.seek(time_position)
|
||||
|
||||
def seek(self, time_position):
|
||||
self.audio.prepare_change()
|
||||
self.backend.spotify.session.seek(time_position)
|
||||
self.audio.start_playback()
|
||||
|
||||
self._timer.seek(time_position)
|
||||
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
|
||||
@ -11,7 +11,9 @@ class SpotifyPlaylistsProvider(base.BasePlaylistsProvider):
|
||||
pass # TODO
|
||||
|
||||
def lookup(self, uri):
|
||||
pass # TODO
|
||||
for playlist in self._playlists:
|
||||
if playlist.uri == uri:
|
||||
return playlist
|
||||
|
||||
def refresh(self):
|
||||
pass # TODO
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
@ -7,6 +11,7 @@ import threading
|
||||
from spotify.manager import SpotifySessionManager as PyspotifySessionManager
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.listener import BackendListener
|
||||
from mopidy.models import Playlist
|
||||
from mopidy.utils import process, versioning
|
||||
|
||||
@ -108,8 +113,13 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
|
||||
'sample_rate': sample_rate,
|
||||
'channels': channels,
|
||||
}
|
||||
self.audio.emit_data(capabilites, bytes(frames))
|
||||
return num_frames
|
||||
buffer_ = gst.Buffer(bytes(frames))
|
||||
buffer_.set_caps(gst.caps_from_string(capabilites))
|
||||
|
||||
if self.audio.emit_data(buffer_).get():
|
||||
return num_frames
|
||||
else:
|
||||
return 0
|
||||
|
||||
def play_token_lost(self, session):
|
||||
"""Callback used by pyspotify"""
|
||||
@ -146,6 +156,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
|
||||
playlists = filter(None, playlists)
|
||||
self.backend.playlists.playlists = playlists
|
||||
logger.info('Loaded %d Spotify playlist(s)', len(playlists))
|
||||
BackendListener.send('playlists_loaded')
|
||||
|
||||
def search(self, query, queue):
|
||||
"""Search method used by Mopidy backend"""
|
||||
|
||||
@ -4,15 +4,17 @@ import itertools
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy.audio import AudioListener
|
||||
from mopidy.audio import AudioListener, PlaybackState
|
||||
from mopidy.backends.listener import BackendListener
|
||||
|
||||
from .library import LibraryController
|
||||
from .listener import CoreListener
|
||||
from .playback import PlaybackController
|
||||
from .playlists import PlaylistsController
|
||||
from .tracklist import TracklistController
|
||||
|
||||
|
||||
class Core(pykka.ThreadingActor, AudioListener):
|
||||
class Core(pykka.ThreadingActor, AudioListener, BackendListener):
|
||||
#: The library controller. An instance of
|
||||
# :class:`mopidy.core.LibraryController`.
|
||||
library = None
|
||||
@ -55,6 +57,22 @@ class Core(pykka.ThreadingActor, AudioListener):
|
||||
def reached_end_of_stream(self):
|
||||
self.playback.on_end_of_track()
|
||||
|
||||
def state_changed(self, old_state, new_state):
|
||||
# XXX: This is a temporary fix for issue #232 while we wait for a more
|
||||
# permanent solution with the implementation of issue #234. When the
|
||||
# Spotify play token is lost, the Spotify backend pauses audio
|
||||
# playback, but mopidy.core doesn't know this, so we need to update
|
||||
# mopidy.core's state to match the actual state in mopidy.audio. If we
|
||||
# don't do this, clients will think that we're still playing.
|
||||
if (new_state == PlaybackState.PAUSED
|
||||
and self.playback.state != PlaybackState.PAUSED):
|
||||
self.playback.state = new_state
|
||||
self.playback._trigger_track_playback_paused()
|
||||
|
||||
def playlists_loaded(self):
|
||||
# Forward event from backend to frontends
|
||||
CoreListener.send('playlists_loaded')
|
||||
|
||||
|
||||
class Backends(list):
|
||||
def __init__(self, backends):
|
||||
@ -66,8 +84,8 @@ class Backends(list):
|
||||
# the X_by_uri_scheme dicts below.
|
||||
self.with_library = [b for b in backends if b.has_library().get()]
|
||||
self.with_playback = [b for b in backends if b.has_playback().get()]
|
||||
self.with_playlists = [b for b in backends
|
||||
if b.has_playlists().get()]
|
||||
self.with_playlists = [
|
||||
b for b in backends if b.has_playlists().get()]
|
||||
|
||||
self.by_uri_scheme = {}
|
||||
for backend in backends:
|
||||
|
||||
@ -84,11 +84,30 @@ class CoreListener(object):
|
||||
"""
|
||||
pass
|
||||
|
||||
def playlist_changed(self):
|
||||
def tracklist_changed(self):
|
||||
"""
|
||||
Called whenever the tracklist is changed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
"""
|
||||
pass
|
||||
|
||||
def playlists_loaded(self):
|
||||
"""
|
||||
Called when playlists are loaded or refreshed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
"""
|
||||
pass
|
||||
|
||||
def playlist_changed(self, playlist):
|
||||
"""
|
||||
Called whenever a playlist is changed.
|
||||
|
||||
*MAY* be implemented by actor.
|
||||
|
||||
:param playlist: the changed playlist
|
||||
:type playlist: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@ -4,6 +4,8 @@ import logging
|
||||
import random
|
||||
import urlparse
|
||||
|
||||
from mopidy.audio import PlaybackState
|
||||
|
||||
from . import listener
|
||||
|
||||
|
||||
@ -24,21 +26,6 @@ def option_wrapper(name, default):
|
||||
return property(get_option, set_option)
|
||||
|
||||
|
||||
class PlaybackState(object):
|
||||
"""
|
||||
Enum of playback states.
|
||||
"""
|
||||
|
||||
#: Constant representing the paused state.
|
||||
PAUSED = 'paused'
|
||||
|
||||
#: Constant representing the playing state.
|
||||
PLAYING = 'playing'
|
||||
|
||||
#: Constant representing the stopped state.
|
||||
STOPPED = 'stopped'
|
||||
|
||||
|
||||
class PlaybackController(object):
|
||||
# pylint: disable = R0902
|
||||
# Too many instance attributes
|
||||
|
||||
@ -5,6 +5,8 @@ import urlparse
|
||||
|
||||
import pykka
|
||||
|
||||
from . import listener
|
||||
|
||||
|
||||
class PlaylistsController(object):
|
||||
pykka_traversable = True
|
||||
@ -20,8 +22,8 @@ class PlaylistsController(object):
|
||||
|
||||
Read-only. List of :class:`mopidy.models.Playlist`.
|
||||
"""
|
||||
futures = [b.playlists.playlists
|
||||
for b in self.backends.with_playlists]
|
||||
futures = [
|
||||
b.playlists.playlists for b in self.backends.with_playlists]
|
||||
results = pykka.get_all(futures)
|
||||
return list(itertools.chain(*results))
|
||||
|
||||
@ -47,7 +49,9 @@ class PlaylistsController(object):
|
||||
backend = self.backends.by_uri_scheme[uri_scheme]
|
||||
else:
|
||||
backend = self.backends.with_playlists[0]
|
||||
return backend.playlists.create(name).get()
|
||||
playlist = backend.playlists.create(name).get()
|
||||
listener.CoreListener.send('playlist_changed', playlist=playlist)
|
||||
return playlist
|
||||
|
||||
def delete(self, uri):
|
||||
"""
|
||||
@ -125,14 +129,16 @@ class PlaylistsController(object):
|
||||
:type uri_scheme: string
|
||||
"""
|
||||
if uri_scheme is None:
|
||||
futures = [b.playlists.refresh()
|
||||
for b in self.backends.with_playlists]
|
||||
futures = [
|
||||
b.playlists.refresh() for b in self.backends.with_playlists]
|
||||
pykka.get_all(futures)
|
||||
listener.CoreListener.send('playlists_loaded')
|
||||
else:
|
||||
backend = self.backends.with_playlists_by_uri_scheme.get(
|
||||
uri_scheme, None)
|
||||
if backend:
|
||||
backend.playlists.refresh().get()
|
||||
listener.CoreListener.send('playlists_loaded')
|
||||
|
||||
def save(self, playlist):
|
||||
"""
|
||||
@ -162,4 +168,6 @@ class PlaylistsController(object):
|
||||
backend = self.backends.with_playlists_by_uri_scheme.get(
|
||||
uri_scheme, None)
|
||||
if backend:
|
||||
return backend.playlists.save(playlist).get()
|
||||
playlist = backend.playlists.save(playlist).get()
|
||||
listener.CoreListener.send('playlist_changed', playlist=playlist)
|
||||
return playlist
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from copy import copy
|
||||
import logging
|
||||
import random
|
||||
|
||||
@ -16,8 +15,8 @@ class TracklistController(object):
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self, core):
|
||||
self.core = core
|
||||
self.tlid = 0
|
||||
self._core = core
|
||||
self._next_tlid = 0
|
||||
self._tl_tracks = []
|
||||
self._version = 0
|
||||
|
||||
@ -28,12 +27,12 @@ class TracklistController(object):
|
||||
|
||||
Read-only.
|
||||
"""
|
||||
return [copy(tl_track) for tl_track in self._tl_tracks]
|
||||
return self._tl_tracks[:]
|
||||
|
||||
@property
|
||||
def tracks(self):
|
||||
"""
|
||||
List of :class:`mopidy.models.Track` in the current playlist.
|
||||
List of :class:`mopidy.models.Track` in the tracklist.
|
||||
|
||||
Read-only.
|
||||
"""
|
||||
@ -42,78 +41,91 @@ class TracklistController(object):
|
||||
@property
|
||||
def length(self):
|
||||
"""
|
||||
Length of the current playlist.
|
||||
Length of the tracklist.
|
||||
"""
|
||||
return len(self._tl_tracks)
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
"""
|
||||
The current playlist version. Integer which is increased every time the
|
||||
current playlist is changed. Is not reset before Mopidy is restarted.
|
||||
The tracklist version. Integer which is increased every time the
|
||||
tracklist is changed. Is not reset before Mopidy is restarted.
|
||||
"""
|
||||
return self._version
|
||||
|
||||
@version.setter # noqa
|
||||
def version(self, version):
|
||||
self._version = version
|
||||
self.core.playback.on_tracklist_change()
|
||||
self._trigger_playlist_changed()
|
||||
self._core.playback.on_tracklist_change()
|
||||
self._trigger_tracklist_changed()
|
||||
|
||||
def add(self, track, at_position=None, increase_version=True):
|
||||
"""
|
||||
Add the track to the end of, or at the given position in the current
|
||||
playlist.
|
||||
Add the track to the end of, or at the given position in the tracklist.
|
||||
|
||||
Triggers the :method:`mopidy.core.CoreListener.tracklist_changed`
|
||||
event.
|
||||
|
||||
:param track: track to add
|
||||
:type track: :class:`mopidy.models.Track`
|
||||
:param at_position: position in current playlist to add track
|
||||
:param at_position: position in tracklist to add track
|
||||
:type at_position: int or :class:`None`
|
||||
:param increase_version: if the playlist version should be increased
|
||||
:param increase_version: if the tracklist version should be increased
|
||||
:type increase_version: :class:`True` or :class:`False`
|
||||
:rtype: two-tuple of (TLID integer, :class:`mopidy.models.Track`) that
|
||||
was added to the current playlist playlist
|
||||
was added to the tracklist
|
||||
"""
|
||||
assert at_position <= len(self._tl_tracks), \
|
||||
'at_position can not be greater than playlist length'
|
||||
tl_track = TlTrack(self.tlid, track)
|
||||
'at_position can not be greater than tracklist length'
|
||||
tl_track = TlTrack(self._next_tlid, track)
|
||||
if at_position is not None:
|
||||
self._tl_tracks.insert(at_position, tl_track)
|
||||
else:
|
||||
self._tl_tracks.append(tl_track)
|
||||
if increase_version:
|
||||
self.version += 1
|
||||
self.tlid += 1
|
||||
self._next_tlid += 1
|
||||
return tl_track
|
||||
|
||||
def append(self, tracks):
|
||||
"""
|
||||
Append the given tracks to the current playlist.
|
||||
Append the given tracks to the tracklist.
|
||||
|
||||
Triggers the :method:`mopidy.core.CoreListener.tracklist_changed`
|
||||
event.
|
||||
|
||||
:param tracks: tracks to append
|
||||
:type tracks: list of :class:`mopidy.models.Track`
|
||||
:rtype: list of class:`mopidy.models.TlTrack`
|
||||
"""
|
||||
tl_tracks = []
|
||||
for track in tracks:
|
||||
self.add(track, increase_version=False)
|
||||
tl_tracks.append(self.add(track, increase_version=False))
|
||||
|
||||
if tracks:
|
||||
self.version += 1
|
||||
|
||||
return tl_tracks
|
||||
|
||||
def clear(self):
|
||||
"""Clear the current playlist."""
|
||||
"""
|
||||
Clear the tracklist.
|
||||
|
||||
Triggers the :method:`mopidy.core.CoreListener.tracklist_changed`
|
||||
event.
|
||||
"""
|
||||
self._tl_tracks = []
|
||||
self.version += 1
|
||||
|
||||
def get(self, **criteria):
|
||||
"""
|
||||
Get track by given criterias from current playlist.
|
||||
Get track by given criterias from tracklist.
|
||||
|
||||
Raises :exc:`LookupError` if a unique match is not found.
|
||||
|
||||
Examples::
|
||||
|
||||
get(tlid=7) # Returns track with TLID 7
|
||||
# (current playlist ID)
|
||||
get(tlid=7) # Returns track with TLID 7 (tracklist ID)
|
||||
get(id=1) # Returns track with ID 1
|
||||
get(uri='xyz') # Returns track with URI 'xyz'
|
||||
get(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz'
|
||||
@ -141,7 +153,7 @@ class TracklistController(object):
|
||||
def index(self, tl_track):
|
||||
"""
|
||||
Get index of the given (TLID integer, :class:`mopidy.models.Track`)
|
||||
two-tuple in the current playlist.
|
||||
two-tuple in the tracklist.
|
||||
|
||||
Raises :exc:`ValueError` if not found.
|
||||
|
||||
@ -155,6 +167,9 @@ class TracklistController(object):
|
||||
"""
|
||||
Move the tracks in the slice ``[start:end]`` to ``to_position``.
|
||||
|
||||
Triggers the :method:`mopidy.core.CoreListener.tracklist_changed`
|
||||
event.
|
||||
|
||||
:param start: position of first track to move
|
||||
:type start: int
|
||||
:param end: position after last track to move
|
||||
@ -170,10 +185,10 @@ class TracklistController(object):
|
||||
assert start < end, 'start must be smaller than end'
|
||||
assert start >= 0, 'start must be at least zero'
|
||||
assert end <= len(tl_tracks), \
|
||||
'end can not be larger than playlist length'
|
||||
'end can not be larger than tracklist length'
|
||||
assert to_position >= 0, 'to_position must be at least zero'
|
||||
assert to_position <= len(tl_tracks), \
|
||||
'to_position can not be larger than playlist length'
|
||||
'to_position can not be larger than tracklist length'
|
||||
|
||||
new_tl_tracks = tl_tracks[:start] + tl_tracks[end:]
|
||||
for tl_track in tl_tracks[start:end]:
|
||||
@ -184,10 +199,13 @@ class TracklistController(object):
|
||||
|
||||
def remove(self, **criteria):
|
||||
"""
|
||||
Remove the track from the current playlist.
|
||||
Remove the track from the tracklist.
|
||||
|
||||
Uses :meth:`get()` to lookup the track to remove.
|
||||
|
||||
Triggers the :method:`mopidy.core.CoreListener.tracklist_changed`
|
||||
event.
|
||||
|
||||
:param criteria: on or more criteria to match by
|
||||
:type criteria: dict
|
||||
"""
|
||||
@ -198,9 +216,12 @@ class TracklistController(object):
|
||||
|
||||
def shuffle(self, start=None, end=None):
|
||||
"""
|
||||
Shuffles the entire playlist. If ``start`` and ``end`` is given only
|
||||
Shuffles the entire tracklist. If ``start`` and ``end`` is given only
|
||||
shuffles the slice ``[start:end]``.
|
||||
|
||||
Triggers the :method:`mopidy.core.CoreListener.tracklist_changed`
|
||||
event.
|
||||
|
||||
:param start: position of first track to shuffle
|
||||
:type start: int or :class:`None`
|
||||
:param end: position after last track to shuffle
|
||||
@ -216,7 +237,7 @@ class TracklistController(object):
|
||||
|
||||
if end is not None:
|
||||
assert end <= len(tl_tracks), 'end can not be larger than ' + \
|
||||
'playlist length'
|
||||
'tracklist length'
|
||||
|
||||
before = tl_tracks[:start or 0]
|
||||
shuffled = tl_tracks[start:end]
|
||||
@ -227,8 +248,8 @@ class TracklistController(object):
|
||||
|
||||
def slice(self, start, end):
|
||||
"""
|
||||
Returns a slice of the current playlist, limited by the given
|
||||
start and end positions.
|
||||
Returns a slice of the tracklist, limited by the given start and end
|
||||
positions.
|
||||
|
||||
:param start: position of first track to include in slice
|
||||
:type start: int
|
||||
@ -236,8 +257,8 @@ class TracklistController(object):
|
||||
:type end: int
|
||||
:rtype: two-tuple of (TLID integer, :class:`mopidy.models.Track`)
|
||||
"""
|
||||
return [copy(tl_track) for tl_track in self._tl_tracks[start:end]]
|
||||
return self._tl_tracks[start:end]
|
||||
|
||||
def _trigger_playlist_changed(self):
|
||||
logger.debug('Triggering playlist changed event')
|
||||
listener.CoreListener.send('playlist_changed')
|
||||
def _trigger_tracklist_changed(self):
|
||||
logger.debug('Triggering event: tracklist_changed()')
|
||||
listener.CoreListener.send('tracklist_changed')
|
||||
|
||||
@ -43,7 +43,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||
def playback_state_changed(self, old_state, new_state):
|
||||
self.send_idle('player')
|
||||
|
||||
def playlist_changed(self):
|
||||
def tracklist_changed(self):
|
||||
self.send_idle('playlist')
|
||||
|
||||
def options_changed(self):
|
||||
|
||||
@ -112,9 +112,7 @@ def list_(context, field, mpd_query=None):
|
||||
``artist``, ``date``, or ``genre``.
|
||||
|
||||
``ARTIST`` is an optional parameter when type is ``album``,
|
||||
``date``, or ``genre``.
|
||||
|
||||
This filters the result list by an artist.
|
||||
``date``, or ``genre``. This filters the result list by an artist.
|
||||
|
||||
*Clarifications:*
|
||||
|
||||
|
||||
@ -57,35 +57,48 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener):
|
||||
self.indicate_server.show()
|
||||
logger.debug('Startup notification sent')
|
||||
|
||||
def _emit_properties_changed(self, *changed_properties):
|
||||
def _emit_properties_changed(self, interface, changed_properties):
|
||||
if self.mpris_object is None:
|
||||
return
|
||||
props_with_new_values = [
|
||||
(p, self.mpris_object.Get(objects.PLAYER_IFACE, p))
|
||||
(p, self.mpris_object.Get(interface, p))
|
||||
for p in changed_properties]
|
||||
self.mpris_object.PropertiesChanged(
|
||||
objects.PLAYER_IFACE, dict(props_with_new_values), [])
|
||||
interface, dict(props_with_new_values), [])
|
||||
|
||||
def track_playback_paused(self, track, time_position):
|
||||
logger.debug('Received track playback paused event')
|
||||
self._emit_properties_changed('PlaybackStatus')
|
||||
logger.debug('Received track_playback_paused event')
|
||||
self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus'])
|
||||
|
||||
def track_playback_resumed(self, track, time_position):
|
||||
logger.debug('Received track playback resumed event')
|
||||
self._emit_properties_changed('PlaybackStatus')
|
||||
logger.debug('Received track_playback_resumed event')
|
||||
self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus'])
|
||||
|
||||
def track_playback_started(self, track):
|
||||
logger.debug('Received track playback started event')
|
||||
self._emit_properties_changed('PlaybackStatus', 'Metadata')
|
||||
logger.debug('Received track_playback_started event')
|
||||
self._emit_properties_changed(
|
||||
objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata'])
|
||||
|
||||
def track_playback_ended(self, track, time_position):
|
||||
logger.debug('Received track playback ended event')
|
||||
self._emit_properties_changed('PlaybackStatus', 'Metadata')
|
||||
logger.debug('Received track_playback_ended event')
|
||||
self._emit_properties_changed(
|
||||
objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata'])
|
||||
|
||||
def volume_changed(self):
|
||||
logger.debug('Received volume changed event')
|
||||
self._emit_properties_changed('Volume')
|
||||
logger.debug('Received volume_changed event')
|
||||
self._emit_properties_changed(objects.PLAYER_IFACE, ['Volume'])
|
||||
|
||||
def seeked(self, time_position_in_ms):
|
||||
logger.debug('Received seeked event')
|
||||
self.mpris_object.Seeked(time_position_in_ms * 1000)
|
||||
|
||||
def playlists_loaded(self):
|
||||
logger.debug('Received playlists_loaded event')
|
||||
self._emit_properties_changed(
|
||||
objects.PLAYLISTS_IFACE, ['PlaylistCount'])
|
||||
|
||||
def playlist_changed(self, playlist):
|
||||
logger.debug('Received playlist_changed event')
|
||||
playlist_id = self.mpris_object.get_playlist_id(playlist.uri)
|
||||
playlist = (playlist_id, playlist.name, '')
|
||||
self.mpris_object.PlaylistChanged(playlist)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
|
||||
@ -27,10 +28,11 @@ BUS_NAME = 'org.mpris.MediaPlayer2.mopidy'
|
||||
OBJECT_PATH = '/org/mpris/MediaPlayer2'
|
||||
ROOT_IFACE = 'org.mpris.MediaPlayer2'
|
||||
PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player'
|
||||
PLAYLISTS_IFACE = 'org.mpris.MediaPlayer2.Playlists'
|
||||
|
||||
|
||||
class MprisObject(dbus.service.Object):
|
||||
"""Implements http://www.mpris.org/2.1/spec/"""
|
||||
"""Implements http://www.mpris.org/2.2/spec/"""
|
||||
|
||||
properties = None
|
||||
|
||||
@ -39,6 +41,7 @@ class MprisObject(dbus.service.Object):
|
||||
self.properties = {
|
||||
ROOT_IFACE: self._get_root_iface_properties(),
|
||||
PLAYER_IFACE: self._get_player_iface_properties(),
|
||||
PLAYLISTS_IFACE: self._get_playlists_iface_properties(),
|
||||
}
|
||||
bus_name = self._connect_to_dbus()
|
||||
dbus.service.Object.__init__(self, bus_name, OBJECT_PATH)
|
||||
@ -46,6 +49,8 @@ class MprisObject(dbus.service.Object):
|
||||
def _get_root_iface_properties(self):
|
||||
return {
|
||||
'CanQuit': (True, None),
|
||||
'Fullscreen': (False, None),
|
||||
'CanSetFullscreen': (False, None),
|
||||
'CanRaise': (False, None),
|
||||
# NOTE Change if adding optional track list support
|
||||
'HasTrackList': (False, None),
|
||||
@ -76,6 +81,13 @@ class MprisObject(dbus.service.Object):
|
||||
'CanControl': (self.get_CanControl, None),
|
||||
}
|
||||
|
||||
def _get_playlists_iface_properties(self):
|
||||
return {
|
||||
'PlaylistCount': (self.get_PlaylistCount, None),
|
||||
'Orderings': (self.get_Orderings, None),
|
||||
'ActivePlaylist': (self.get_ActivePlaylist, None),
|
||||
}
|
||||
|
||||
def _connect_to_dbus(self):
|
||||
logger.debug('Connecting to D-Bus...')
|
||||
mainloop = dbus.mainloop.glib.DBusGMainLoop()
|
||||
@ -84,10 +96,22 @@ class MprisObject(dbus.service.Object):
|
||||
logger.info('Connected to D-Bus')
|
||||
return bus_name
|
||||
|
||||
def _get_track_id(self, tl_track):
|
||||
def get_playlist_id(self, playlist_uri):
|
||||
# Only A-Za-z0-9_ is allowed, which is 63 chars, so we can't use
|
||||
# base64. Luckily, D-Bus does not limit the length of object paths.
|
||||
# Since base32 pads trailing bytes with "=" chars, we need to replace
|
||||
# them with an allowed character such as "_".
|
||||
encoded_uri = base64.b32encode(playlist_uri).replace('=', '_')
|
||||
return '/com/mopidy/playlist/%s' % encoded_uri
|
||||
|
||||
def get_playlist_uri(self, playlist_id):
|
||||
encoded_uri = playlist_id.split('/')[-1].replace('_', '=')
|
||||
return base64.b32decode(encoded_uri)
|
||||
|
||||
def get_track_id(self, tl_track):
|
||||
return '/com/mopidy/track/%d' % tl_track.tlid
|
||||
|
||||
def _get_tlid(self, track_id):
|
||||
def get_track_tlid(self, track_id):
|
||||
assert track_id.startswith('/com/mopidy/track/')
|
||||
return track_id.split('/')[-1]
|
||||
|
||||
@ -237,7 +261,7 @@ class MprisObject(dbus.service.Object):
|
||||
current_tl_track = self.core.playback.current_tl_track.get()
|
||||
if current_tl_track is None:
|
||||
return
|
||||
if track_id != self._get_track_id(current_tl_track):
|
||||
if track_id != self.get_track_id(current_tl_track):
|
||||
return
|
||||
if position < 0:
|
||||
return
|
||||
@ -335,7 +359,7 @@ class MprisObject(dbus.service.Object):
|
||||
return {'mpris:trackid': ''}
|
||||
else:
|
||||
(_, track) = current_tl_track
|
||||
metadata = {'mpris:trackid': self._get_track_id(current_tl_track)}
|
||||
metadata = {'mpris:trackid': self.get_track_id(current_tl_track)}
|
||||
if track.length:
|
||||
metadata['mpris:length'] = track.length * 1000
|
||||
if track.uri:
|
||||
@ -418,3 +442,58 @@ class MprisObject(dbus.service.Object):
|
||||
def get_CanControl(self):
|
||||
# NOTE This could be a setting for the end user to change.
|
||||
return True
|
||||
|
||||
### Playlists interface methods
|
||||
|
||||
@dbus.service.method(dbus_interface=PLAYLISTS_IFACE)
|
||||
def ActivatePlaylist(self, playlist_id):
|
||||
logger.debug(
|
||||
'%s.ActivatePlaylist(%r) called', PLAYLISTS_IFACE, playlist_id)
|
||||
playlist_uri = self.get_playlist_uri(playlist_id)
|
||||
playlist = self.core.playlists.lookup(playlist_uri).get()
|
||||
if playlist and playlist.tracks:
|
||||
tl_tracks = self.core.tracklist.append(playlist.tracks).get()
|
||||
self.core.playback.play(tl_tracks[0])
|
||||
|
||||
@dbus.service.method(dbus_interface=PLAYLISTS_IFACE)
|
||||
def GetPlaylists(self, index, max_count, order, reverse):
|
||||
logger.debug(
|
||||
'%s.GetPlaylists(%r, %r, %r, %r) called',
|
||||
PLAYLISTS_IFACE, index, max_count, order, reverse)
|
||||
playlists = self.core.playlists.playlists.get()
|
||||
if order == 'Alphabetical':
|
||||
playlists.sort(key=lambda p: p.name, reverse=reverse)
|
||||
elif order == 'Modified':
|
||||
playlists.sort(key=lambda p: p.last_modified, reverse=reverse)
|
||||
elif order == 'User' and reverse:
|
||||
playlists.reverse()
|
||||
slice_end = index + max_count
|
||||
playlists = playlists[index:slice_end]
|
||||
results = [
|
||||
(self.get_playlist_id(p.uri), p.name, '')
|
||||
for p in playlists]
|
||||
return dbus.Array(results, signature='(oss)')
|
||||
|
||||
### Playlists interface signals
|
||||
|
||||
@dbus.service.signal(dbus_interface=PLAYLISTS_IFACE, signature='(oss)')
|
||||
def PlaylistChanged(self, playlist):
|
||||
logger.debug('%s.PlaylistChanged signaled', PLAYLISTS_IFACE)
|
||||
# Do nothing, as just calling the method is enough to emit the signal.
|
||||
|
||||
### Playlists interface properties
|
||||
|
||||
def get_PlaylistCount(self):
|
||||
return len(self.core.playlists.playlists.get())
|
||||
|
||||
def get_Orderings(self):
|
||||
return [
|
||||
'Alphabetical', # Order by playlist.name
|
||||
'Modified', # Order by playlist.last_modified
|
||||
'User', # Don't change order
|
||||
]
|
||||
|
||||
def get_ActivePlaylist(self):
|
||||
playlist_is_valid = False
|
||||
playlist = ('/', 'None', '')
|
||||
return (playlist_is_valid, playlist)
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
|
||||
class ImmutableObject(object):
|
||||
"""
|
||||
@ -151,9 +149,6 @@ class Album(ImmutableObject):
|
||||
super(Album, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
TlTrack = namedtuple('TlTrack', ['tlid', 'track'])
|
||||
|
||||
|
||||
class Track(ImmutableObject):
|
||||
"""
|
||||
:param uri: track URI
|
||||
@ -208,6 +203,44 @@ class Track(ImmutableObject):
|
||||
super(Track, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class TlTrack(ImmutableObject):
|
||||
"""
|
||||
A tracklist track. Wraps a regular track and it's tracklist ID.
|
||||
|
||||
The use of :class:`TlTrack` allows the same track to appear multiple times
|
||||
in the tracklist.
|
||||
|
||||
This class also accepts it's parameters as positional arguments. Both
|
||||
arguments must be provided, and they must appear in the order they are
|
||||
listed here.
|
||||
|
||||
This class also supports iteration, so your extract its values like this::
|
||||
|
||||
(tlid, track) = tl_track
|
||||
|
||||
:param tlid: tracklist ID
|
||||
:type tlid: int
|
||||
:param track: the track
|
||||
:type track: :class:`Track`
|
||||
"""
|
||||
|
||||
#: The tracklist ID. Read-only.
|
||||
tlid = None
|
||||
|
||||
#: The track. Read-only.
|
||||
track = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if len(args) == 2 and len(kwargs) == 0:
|
||||
kwargs['tlid'] = args[0]
|
||||
kwargs['track'] = args[1]
|
||||
args = []
|
||||
super(TlTrack, self).__init__(*args, **kwargs)
|
||||
|
||||
def __iter__(self):
|
||||
return iter([self.tlid, self.track])
|
||||
|
||||
|
||||
class Playlist(ImmutableObject):
|
||||
"""
|
||||
:param uri: playlist URI
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
@ -7,10 +10,40 @@ import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
import datetime
|
||||
|
||||
from mopidy.utils.path import path_to_uri, find_files
|
||||
from mopidy import settings
|
||||
from mopidy.frontends.mpd import translator as mpd_translator
|
||||
from mopidy.models import Track, Artist, Album
|
||||
from mopidy.utils import log, path
|
||||
|
||||
|
||||
def main():
|
||||
log.setup_root_logger()
|
||||
log.setup_console_logging(2)
|
||||
|
||||
tracks = []
|
||||
|
||||
def store(data):
|
||||
track = translator(data)
|
||||
tracks.append(track)
|
||||
logging.debug('Added %s', track.uri)
|
||||
|
||||
def debug(uri, error, debug):
|
||||
logging.error('Failed %s: %s - %s', uri, error, debug)
|
||||
|
||||
logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH)
|
||||
scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug)
|
||||
try:
|
||||
scanner.start()
|
||||
except KeyboardInterrupt:
|
||||
scanner.stop()
|
||||
|
||||
logging.info('Done')
|
||||
|
||||
for row in mpd_translator.tracks_to_tag_cache_format(tracks):
|
||||
if len(row) == 1:
|
||||
print ('%s' % row).encode('utf-8')
|
||||
else:
|
||||
print ('%s: %s' % row).encode('utf-8')
|
||||
|
||||
|
||||
def translator(data):
|
||||
@ -56,51 +89,70 @@ def translator(data):
|
||||
|
||||
class Scanner(object):
|
||||
def __init__(self, folder, data_callback, error_callback=None):
|
||||
self.files = find_files(folder)
|
||||
self.data = {}
|
||||
self.files = path.find_files(folder)
|
||||
self.data_callback = data_callback
|
||||
self.error_callback = error_callback
|
||||
self.loop = gobject.MainLoop()
|
||||
|
||||
fakesink = gst.element_factory_make('fakesink')
|
||||
self.fakesink = gst.element_factory_make('fakesink')
|
||||
self.fakesink.set_property('signal-handoffs', True)
|
||||
self.fakesink.connect('handoff', self.process_handoff)
|
||||
|
||||
self.uribin = gst.element_factory_make('uridecodebin')
|
||||
self.uribin.set_property('caps', gst.Caps(b'audio/x-raw-int'))
|
||||
self.uribin.connect(
|
||||
'pad-added', self.process_new_pad, fakesink.get_pad('sink'))
|
||||
self.uribin.connect('pad-added', self.process_new_pad)
|
||||
|
||||
self.pipe = gst.element_factory_make('pipeline')
|
||||
self.pipe.add(self.uribin)
|
||||
self.pipe.add(fakesink)
|
||||
self.pipe.add(self.fakesink)
|
||||
|
||||
bus = self.pipe.get_bus()
|
||||
bus.add_signal_watch()
|
||||
bus.connect('message::application', self.process_application)
|
||||
bus.connect('message::tag', self.process_tags)
|
||||
bus.connect('message::error', self.process_error)
|
||||
|
||||
def process_new_pad(self, source, pad, target_pad):
|
||||
pad.link(target_pad)
|
||||
def process_handoff(self, fakesink, buffer_, pad):
|
||||
# When this function is called the first buffer has reached the end of
|
||||
# the pipeline, and we can continue with the next track. Since we're
|
||||
# in another thread, we send a message back to the main thread using
|
||||
# the bus.
|
||||
structure = gst.Structure('handoff')
|
||||
message = gst.message_new_application(fakesink, structure)
|
||||
bus = self.pipe.get_bus()
|
||||
bus.post(message)
|
||||
|
||||
def process_new_pad(self, source, pad):
|
||||
pad.link(self.fakesink.get_pad('sink'))
|
||||
|
||||
def process_application(self, bus, message):
|
||||
if message.src != self.fakesink:
|
||||
return
|
||||
|
||||
if message.structure.get_name() != 'handoff':
|
||||
return
|
||||
|
||||
self.data['uri'] = unicode(self.uribin.get_property('uri'))
|
||||
self.data[gst.TAG_DURATION] = self.get_duration()
|
||||
|
||||
try:
|
||||
self.data_callback(self.data)
|
||||
self.next_uri()
|
||||
except KeyboardInterrupt:
|
||||
self.stop()
|
||||
|
||||
def process_tags(self, bus, message):
|
||||
taglist = message.parse_tag()
|
||||
data = {
|
||||
'uri': unicode(self.uribin.get_property('uri')),
|
||||
gst.TAG_DURATION: self.get_duration(),
|
||||
}
|
||||
|
||||
for key in taglist.keys():
|
||||
# XXX: For some crazy reason some wma files spit out lists here,
|
||||
# not sure if this is due to better data in headers or wma being
|
||||
# stupid. So ugly hack for now :/
|
||||
if type(taglist[key]) is list:
|
||||
data[key] = taglist[key][0]
|
||||
self.data[key] = taglist[key][0]
|
||||
else:
|
||||
data[key] = taglist[key]
|
||||
|
||||
try:
|
||||
self.data_callback(data)
|
||||
self.next_uri()
|
||||
except KeyboardInterrupt:
|
||||
self.stop()
|
||||
self.data[key] = taglist[key]
|
||||
|
||||
def process_error(self, bus, message):
|
||||
if self.error_callback:
|
||||
@ -118,14 +170,15 @@ class Scanner(object):
|
||||
return None
|
||||
|
||||
def next_uri(self):
|
||||
self.data = {}
|
||||
try:
|
||||
uri = path_to_uri(self.files.next())
|
||||
uri = path.path_to_uri(self.files.next())
|
||||
except StopIteration:
|
||||
self.stop()
|
||||
return False
|
||||
self.pipe.set_state(gst.STATE_NULL)
|
||||
self.uribin.set_property('uri', uri)
|
||||
self.pipe.set_state(gst.STATE_PAUSED)
|
||||
self.pipe.set_state(gst.STATE_PLAYING)
|
||||
return True
|
||||
|
||||
def start(self):
|
||||
|
||||
@ -135,15 +135,9 @@ def _gstreamer_check_elements():
|
||||
|
||||
|
||||
def pykka_info():
|
||||
if hasattr(pykka, '__version__'):
|
||||
# Pykka >= 0.14
|
||||
version = pykka.__version__
|
||||
else:
|
||||
# Pykka < 0.14
|
||||
version = pykka.get_version()
|
||||
return {
|
||||
'name': 'Pykka',
|
||||
'version': version,
|
||||
'version': pykka.__version__,
|
||||
'path': pykka.__file__,
|
||||
}
|
||||
|
||||
|
||||
@ -140,7 +140,7 @@ class Connection(object):
|
||||
self.timeout = timeout
|
||||
|
||||
self.send_lock = threading.Lock()
|
||||
self.send_buffer = ''
|
||||
self.send_buffer = b''
|
||||
|
||||
self.stopping = False
|
||||
|
||||
@ -193,7 +193,7 @@ class Connection(object):
|
||||
if e.errno in (errno.EWOULDBLOCK, errno.EINTR):
|
||||
return data
|
||||
self.stop('Unexpected client error: %s' % e)
|
||||
return ''
|
||||
return b''
|
||||
|
||||
def enable_timeout(self):
|
||||
"""Reactivate timeout mechanism."""
|
||||
|
||||
0
tests/audio/__init__.py
Normal file
0
tests/audio/__init__.py
Normal file
@ -1,5 +1,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
from mopidy import audio, settings
|
||||
from mopidy.utils.path import path_to_uri
|
||||
|
||||
@ -63,3 +67,48 @@ class AudioTest(unittest.TestCase):
|
||||
@unittest.SkipTest
|
||||
def test_invalid_output_raises_error(self):
|
||||
pass # TODO
|
||||
|
||||
|
||||
class AudioStateTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.audio = audio.Audio()
|
||||
|
||||
def test_state_starts_as_stopped(self):
|
||||
self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state)
|
||||
|
||||
def test_state_does_not_change_when_in_gst_ready_state(self):
|
||||
self.audio._on_playbin_state_changed(
|
||||
gst.STATE_NULL, gst.STATE_READY, gst.STATE_VOID_PENDING)
|
||||
|
||||
self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state)
|
||||
|
||||
def test_state_changes_from_stopped_to_playing_on_play(self):
|
||||
self.audio._on_playbin_state_changed(
|
||||
gst.STATE_NULL, gst.STATE_READY, gst.STATE_PLAYING)
|
||||
self.audio._on_playbin_state_changed(
|
||||
gst.STATE_READY, gst.STATE_PAUSED, gst.STATE_PLAYING)
|
||||
self.audio._on_playbin_state_changed(
|
||||
gst.STATE_PAUSED, gst.STATE_PLAYING, gst.STATE_VOID_PENDING)
|
||||
|
||||
self.assertEqual(audio.PlaybackState.PLAYING, self.audio.state)
|
||||
|
||||
def test_state_changes_from_playing_to_paused_on_pause(self):
|
||||
self.audio.state = audio.PlaybackState.PLAYING
|
||||
|
||||
self.audio._on_playbin_state_changed(
|
||||
gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_VOID_PENDING)
|
||||
|
||||
self.assertEqual(audio.PlaybackState.PAUSED, self.audio.state)
|
||||
|
||||
def test_state_changes_from_playing_to_stopped_on_stop(self):
|
||||
self.audio.state = audio.PlaybackState.PLAYING
|
||||
|
||||
self.audio._on_playbin_state_changed(
|
||||
gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_NULL)
|
||||
self.audio._on_playbin_state_changed(
|
||||
gst.STATE_PAUSED, gst.STATE_READY, gst.STATE_NULL)
|
||||
# We never get the following call, so the logic must work without it
|
||||
#self.audio._on_playbin_state_changed(
|
||||
# gst.STATE_READY, gst.STATE_NULL, gst.STATE_VOID_PENDING)
|
||||
|
||||
self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state)
|
||||
16
tests/audio/listener_test.py
Normal file
16
tests/audio/listener_test.py
Normal file
@ -0,0 +1,16 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mopidy import audio
|
||||
|
||||
from tests import unittest
|
||||
|
||||
|
||||
class AudioListenerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.listener = audio.AudioListener()
|
||||
|
||||
def test_listener_has_default_impl_for_reached_end_of_stream(self):
|
||||
self.listener.reached_end_of_stream()
|
||||
|
||||
def test_listener_has_default_impl_for_state_changed(self):
|
||||
self.listener.state_changed(None, None)
|
||||
23
tests/backends/base/events.py
Normal file
23
tests/backends/base/events.py
Normal file
@ -0,0 +1,23 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import mock
|
||||
import pykka
|
||||
|
||||
from mopidy import core, audio
|
||||
from mopidy.backends import listener
|
||||
|
||||
|
||||
@mock.patch.object(listener.BackendListener, 'send')
|
||||
class BackendEventsTest(object):
|
||||
def setUp(self):
|
||||
self.audio = mock.Mock(spec=audio.Audio)
|
||||
self.backend = self.backend_class.start(audio=audio).proxy()
|
||||
self.core = core.Core.start(backends=[self.backend]).proxy()
|
||||
|
||||
def tearDown(self):
|
||||
pykka.ActorRegistry.stop_all()
|
||||
|
||||
def test_playlists_refresh_sends_playlists_loaded_event(self, send):
|
||||
send.reset_mock()
|
||||
self.core.playlists.refresh().get()
|
||||
self.assertEqual(send.call_args[0][0], 'playlists_loaded')
|
||||
@ -88,7 +88,7 @@ class TracklistControllerTest(object):
|
||||
def test_get_by_uri_returns_unique_match(self):
|
||||
track = Track(uri='a')
|
||||
self.controller.append([Track(uri='z'), track, Track(uri='y')])
|
||||
self.assertEqual(track, self.controller.get(uri='a')[1])
|
||||
self.assertEqual(track, self.controller.get(uri='a').track)
|
||||
|
||||
def test_get_by_uri_raises_error_if_multiple_matches(self):
|
||||
track = Track(uri='a')
|
||||
@ -113,16 +113,16 @@ class TracklistControllerTest(object):
|
||||
track2 = Track(uri='b', name='x')
|
||||
track3 = Track(uri='b', name='y')
|
||||
self.controller.append([track1, track2, track3])
|
||||
self.assertEqual(track1, self.controller.get(uri='a', name='x')[1])
|
||||
self.assertEqual(track2, self.controller.get(uri='b', name='x')[1])
|
||||
self.assertEqual(track3, self.controller.get(uri='b', name='y')[1])
|
||||
self.assertEqual(track1, self.controller.get(uri='a', name='x').track)
|
||||
self.assertEqual(track2, self.controller.get(uri='b', name='x').track)
|
||||
self.assertEqual(track3, self.controller.get(uri='b', name='y').track)
|
||||
|
||||
def test_get_by_criteria_that_is_not_present_in_all_elements(self):
|
||||
track1 = Track()
|
||||
track2 = Track(uri='b')
|
||||
track3 = Track()
|
||||
self.controller.append([track1, track2, track3])
|
||||
self.assertEqual(track2, self.controller.get(uri='b')[1])
|
||||
self.assertEqual(track2, self.controller.get(uri='b').track)
|
||||
|
||||
def test_append_appends_to_the_tracklist(self):
|
||||
self.controller.append([Track(uri='a'), Track(uri='b')])
|
||||
@ -153,10 +153,13 @@ class TracklistControllerTest(object):
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
self.assertEqual(self.playback.current_track, None)
|
||||
|
||||
@populate_playlist
|
||||
def test_append_returns_the_tl_tracks_that_was_added(self):
|
||||
tl_tracks = self.controller.append(self.controller.tracks[1:2])
|
||||
self.assertEqual(tl_tracks[0].track, self.controller.tracks[1])
|
||||
|
||||
def test_index_returns_index_of_track(self):
|
||||
tl_tracks = []
|
||||
for track in self.tracks:
|
||||
tl_tracks.append(self.controller.add(track))
|
||||
tl_tracks = self.controller.append(self.tracks)
|
||||
self.assertEquals(0, self.controller.index(tl_tracks[0]))
|
||||
self.assertEquals(1, self.controller.index(tl_tracks[1]))
|
||||
self.assertEquals(2, self.controller.index(tl_tracks[2]))
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import mock
|
||||
import pykka
|
||||
|
||||
from mopidy import audio, core
|
||||
from mopidy.backends import dummy
|
||||
from mopidy.models import Track
|
||||
|
||||
from tests import unittest
|
||||
|
||||
|
||||
@mock.patch.object(core.CoreListener, 'send')
|
||||
class BackendEventsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.audio = mock.Mock(spec=audio.Audio)
|
||||
self.backend = dummy.DummyBackend.start(audio=audio).proxy()
|
||||
self.core = core.Core.start(backends=[self.backend]).proxy()
|
||||
|
||||
def tearDown(self):
|
||||
pykka.ActorRegistry.stop_all()
|
||||
|
||||
def test_pause_sends_track_playback_paused_event(self, send):
|
||||
self.core.tracklist.add(Track(uri='dummy:a'))
|
||||
self.core.playback.play().get()
|
||||
send.reset_mock()
|
||||
self.core.playback.pause().get()
|
||||
self.assertEqual(send.call_args[0][0], 'track_playback_paused')
|
||||
|
||||
def test_resume_sends_track_playback_resumed(self, send):
|
||||
self.core.tracklist.add(Track(uri='dummy:a'))
|
||||
self.core.playback.play()
|
||||
self.core.playback.pause().get()
|
||||
send.reset_mock()
|
||||
self.core.playback.resume().get()
|
||||
self.assertEqual(send.call_args[0][0], 'track_playback_resumed')
|
||||
|
||||
def test_play_sends_track_playback_started_event(self, send):
|
||||
self.core.tracklist.add(Track(uri='dummy:a'))
|
||||
send.reset_mock()
|
||||
self.core.playback.play().get()
|
||||
self.assertEqual(send.call_args[0][0], 'track_playback_started')
|
||||
|
||||
def test_stop_sends_track_playback_ended_event(self, send):
|
||||
self.core.tracklist.add(Track(uri='dummy:a'))
|
||||
self.core.playback.play().get()
|
||||
send.reset_mock()
|
||||
self.core.playback.stop().get()
|
||||
self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended')
|
||||
|
||||
def test_seek_sends_seeked_event(self, send):
|
||||
self.core.tracklist.add(Track(uri='dummy:a', length=40000))
|
||||
self.core.playback.play().get()
|
||||
send.reset_mock()
|
||||
self.core.playback.seek(1000).get()
|
||||
self.assertEqual(send.call_args[0][0], 'seeked')
|
||||
13
tests/backends/listener_test.py
Normal file
13
tests/backends/listener_test.py
Normal file
@ -0,0 +1,13 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mopidy.backends.listener import BackendListener
|
||||
|
||||
from tests import unittest
|
||||
|
||||
|
||||
class CoreListenerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.listener = BackendListener()
|
||||
|
||||
def test_listener_has_default_impl_for_playlists_loaded(self):
|
||||
self.listener.playlists_loaded()
|
||||
8
tests/backends/local/events_test.py
Normal file
8
tests/backends/local/events_test.py
Normal file
@ -0,0 +1,8 @@
|
||||
from mopidy.backends.local import LocalBackend
|
||||
|
||||
from tests import unittest
|
||||
from tests.backends.base import events
|
||||
|
||||
|
||||
class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase):
|
||||
backend_class = LocalBackend
|
||||
124
tests/core/events_test.py
Normal file
124
tests/core/events_test.py
Normal file
@ -0,0 +1,124 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import mock
|
||||
import pykka
|
||||
|
||||
from mopidy import audio, core
|
||||
from mopidy.backends import dummy
|
||||
from mopidy.models import Track
|
||||
|
||||
from tests import unittest
|
||||
|
||||
|
||||
@mock.patch.object(core.CoreListener, 'send')
|
||||
class BackendEventsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.audio = mock.Mock(spec=audio.Audio)
|
||||
self.backend = dummy.DummyBackend.start(audio=audio).proxy()
|
||||
self.core = core.Core.start(backends=[self.backend]).proxy()
|
||||
|
||||
def tearDown(self):
|
||||
pykka.ActorRegistry.stop_all()
|
||||
|
||||
def test_backends_playlists_loaded_forwards_event_to_frontends(self, send):
|
||||
send.reset_mock()
|
||||
self.core.playlists_loaded().get()
|
||||
self.assertEqual(send.call_args[0][0], 'playlists_loaded')
|
||||
|
||||
def test_pause_sends_track_playback_paused_event(self, send):
|
||||
self.core.tracklist.add(Track(uri='dummy:a'))
|
||||
self.core.playback.play().get()
|
||||
send.reset_mock()
|
||||
self.core.playback.pause().get()
|
||||
self.assertEqual(send.call_args[0][0], 'track_playback_paused')
|
||||
|
||||
def test_resume_sends_track_playback_resumed(self, send):
|
||||
self.core.tracklist.add(Track(uri='dummy:a'))
|
||||
self.core.playback.play()
|
||||
self.core.playback.pause().get()
|
||||
send.reset_mock()
|
||||
self.core.playback.resume().get()
|
||||
self.assertEqual(send.call_args[0][0], 'track_playback_resumed')
|
||||
|
||||
def test_play_sends_track_playback_started_event(self, send):
|
||||
self.core.tracklist.add(Track(uri='dummy:a'))
|
||||
send.reset_mock()
|
||||
self.core.playback.play().get()
|
||||
self.assertEqual(send.call_args[0][0], 'track_playback_started')
|
||||
|
||||
def test_stop_sends_track_playback_ended_event(self, send):
|
||||
self.core.tracklist.add(Track(uri='dummy:a'))
|
||||
self.core.playback.play().get()
|
||||
send.reset_mock()
|
||||
self.core.playback.stop().get()
|
||||
self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended')
|
||||
|
||||
def test_seek_sends_seeked_event(self, send):
|
||||
self.core.tracklist.add(Track(uri='dummy:a', length=40000))
|
||||
self.core.playback.play().get()
|
||||
send.reset_mock()
|
||||
self.core.playback.seek(1000).get()
|
||||
self.assertEqual(send.call_args[0][0], 'seeked')
|
||||
|
||||
def test_tracklist_add_sends_tracklist_changed_event(self, send):
|
||||
send.reset_mock()
|
||||
self.core.tracklist.add(Track(uri='dummy:a')).get()
|
||||
self.assertEqual(send.call_args[0][0], 'tracklist_changed')
|
||||
|
||||
def test_tracklist_append_sends_tracklist_changed_event(self, send):
|
||||
send.reset_mock()
|
||||
self.core.tracklist.append([Track(uri='dummy:a')]).get()
|
||||
self.assertEqual(send.call_args[0][0], 'tracklist_changed')
|
||||
|
||||
def test_tracklist_clear_sends_tracklist_changed_event(self, send):
|
||||
self.core.tracklist.append([Track(uri='dummy:a')]).get()
|
||||
send.reset_mock()
|
||||
self.core.tracklist.clear().get()
|
||||
self.assertEqual(send.call_args[0][0], 'tracklist_changed')
|
||||
|
||||
def test_tracklist_move_sends_tracklist_changed_event(self, send):
|
||||
self.core.tracklist.append(
|
||||
[Track(uri='dummy:a'), Track(uri='dummy:b')]).get()
|
||||
send.reset_mock()
|
||||
self.core.tracklist.move(0, 1, 1).get()
|
||||
self.assertEqual(send.call_args[0][0], 'tracklist_changed')
|
||||
|
||||
def test_tracklist_remove_sends_tracklist_changed_event(self, send):
|
||||
self.core.tracklist.append([Track(uri='dummy:a')]).get()
|
||||
send.reset_mock()
|
||||
self.core.tracklist.remove(uri='dummy:a').get()
|
||||
self.assertEqual(send.call_args[0][0], 'tracklist_changed')
|
||||
|
||||
def test_tracklist_shuffle_sends_tracklist_changed_event(self, send):
|
||||
self.core.tracklist.append(
|
||||
[Track(uri='dummy:a'), Track(uri='dummy:b')]).get()
|
||||
send.reset_mock()
|
||||
self.core.tracklist.shuffle().get()
|
||||
self.assertEqual(send.call_args[0][0], 'tracklist_changed')
|
||||
|
||||
def test_playlists_refresh_sends_playlists_loaded_event(self, send):
|
||||
send.reset_mock()
|
||||
self.core.playlists.refresh().get()
|
||||
self.assertEqual(send.call_args[0][0], 'playlists_loaded')
|
||||
|
||||
def test_playlists_refresh_uri_sends_playlists_loaded_event(self, send):
|
||||
send.reset_mock()
|
||||
self.core.playlists.refresh(uri_scheme='dummy').get()
|
||||
self.assertEqual(send.call_args[0][0], 'playlists_loaded')
|
||||
|
||||
def test_playlists_create_sends_playlist_changed_event(self, send):
|
||||
send.reset_mock()
|
||||
self.core.playlists.create('foo').get()
|
||||
self.assertEqual(send.call_args[0][0], 'playlist_changed')
|
||||
|
||||
@unittest.SkipTest
|
||||
def test_playlists_delete_sends_playlist_deleted_event(self, send):
|
||||
# TODO We should probably add a playlist_deleted event
|
||||
pass
|
||||
|
||||
def test_playlists_save_sends_playlist_changed_event(self, send):
|
||||
playlist = self.core.playlists.create('foo').get()
|
||||
send.reset_mock()
|
||||
playlist = playlist.copy(name='bar')
|
||||
self.core.playlists.save(playlist).get()
|
||||
self.assertEqual(send.call_args[0][0], 'playlist_changed')
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mopidy.core import CoreListener, PlaybackState
|
||||
from mopidy.models import Track
|
||||
from mopidy.models import Playlist, Track
|
||||
|
||||
from tests import unittest
|
||||
|
||||
@ -26,8 +26,14 @@ class CoreListenerTest(unittest.TestCase):
|
||||
self.listener.playback_state_changed(
|
||||
PlaybackState.STOPPED, PlaybackState.PLAYING)
|
||||
|
||||
def test_listener_has_default_impl_for_tracklist_changed(self):
|
||||
self.listener.tracklist_changed()
|
||||
|
||||
def test_listener_has_default_impl_for_playlists_loaded(self):
|
||||
self.listener.playlists_loaded()
|
||||
|
||||
def test_listener_has_default_impl_for_playlist_changed(self):
|
||||
self.listener.playlist_changed()
|
||||
self.listener.playlist_changed(Playlist())
|
||||
|
||||
def test_listener_has_default_impl_for_options_changed(self):
|
||||
self.listener.options_changed()
|
||||
|
||||
@ -41,7 +41,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase):
|
||||
self.assertEqual(len(self.core.tracklist.tracks.get()), 6)
|
||||
self.assertEqual(self.core.tracklist.tracks.get()[5], needle)
|
||||
self.assertInResponse(
|
||||
'Id: %d' % self.core.tracklist.tl_tracks.get()[5][0])
|
||||
'Id: %d' % self.core.tracklist.tl_tracks.get()[5].tlid)
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_addid_with_empty_uri_acks(self):
|
||||
@ -60,7 +60,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase):
|
||||
self.assertEqual(len(self.core.tracklist.tracks.get()), 6)
|
||||
self.assertEqual(self.core.tracklist.tracks.get()[3], needle)
|
||||
self.assertInResponse(
|
||||
'Id: %d' % self.core.tracklist.tl_tracks.get()[3][0])
|
||||
'Id: %d' % self.core.tracklist.tl_tracks.get()[3].tlid)
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_addid_with_songpos_out_of_bounds_should_ack(self):
|
||||
@ -94,7 +94,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase):
|
||||
self.assertEqual(len(self.core.tracklist.tracks.get()), 5)
|
||||
|
||||
self.sendRequest(
|
||||
'delete "%d"' % self.core.tracklist.tl_tracks.get()[2][0])
|
||||
'delete "%d"' % self.core.tracklist.tl_tracks.get()[2].tlid)
|
||||
self.assertEqual(len(self.core.tracklist.tracks.get()), 4)
|
||||
self.assertInResponse('OK')
|
||||
|
||||
@ -424,11 +424,11 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase):
|
||||
self.sendRequest('plchangesposid "0"')
|
||||
tl_tracks = self.core.tracklist.tl_tracks.get()
|
||||
self.assertInResponse('cpos: 0')
|
||||
self.assertInResponse('Id: %d' % tl_tracks[0][0])
|
||||
self.assertInResponse('Id: %d' % tl_tracks[0].tlid)
|
||||
self.assertInResponse('cpos: 2')
|
||||
self.assertInResponse('Id: %d' % tl_tracks[1][0])
|
||||
self.assertInResponse('Id: %d' % tl_tracks[1].tlid)
|
||||
self.assertInResponse('cpos: 2')
|
||||
self.assertInResponse('Id: %d' % tl_tracks[2][0])
|
||||
self.assertInResponse('Id: %d' % tl_tracks[2].tlid)
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_shuffle_without_range(self):
|
||||
|
||||
@ -5,7 +5,7 @@ import sys
|
||||
import mock
|
||||
|
||||
from mopidy.exceptions import OptionalDependencyError
|
||||
from mopidy.models import Track
|
||||
from mopidy.models import Playlist, Track
|
||||
|
||||
try:
|
||||
from mopidy.frontends.mpris import MprisFrontend, objects
|
||||
@ -75,3 +75,19 @@ class BackendEventsTest(unittest.TestCase):
|
||||
def test_seeked_event_causes_mpris_seeked_event(self):
|
||||
self.mpris_frontend.seeked(31000)
|
||||
self.mpris_object.Seeked.assert_called_with(31000000)
|
||||
|
||||
def test_playlists_loaded_event_changes_playlist_count(self):
|
||||
self.mpris_object.Get.return_value = 17
|
||||
self.mpris_frontend.playlists_loaded()
|
||||
self.assertListEqual(self.mpris_object.Get.call_args_list, [
|
||||
((objects.PLAYLISTS_IFACE, 'PlaylistCount'), {}),
|
||||
])
|
||||
self.mpris_object.PropertiesChanged.assert_called_with(
|
||||
objects.PLAYLISTS_IFACE, {'PlaylistCount': 17}, [])
|
||||
|
||||
def test_playlist_changed_event_causes_mpris_playlist_changed_event(self):
|
||||
self.mpris_object.get_playlist_id.return_value = 'id-for-dummy:foo'
|
||||
playlist = Playlist(uri='dummy:foo', name='foo')
|
||||
self.mpris_frontend.playlist_changed(playlist)
|
||||
self.mpris_object.PlaylistChanged.assert_called_with(
|
||||
('id-for-dummy:foo', 'foo', ''))
|
||||
|
||||
171
tests/frontends/mpris/playlists_interface_test.py
Normal file
171
tests/frontends/mpris/playlists_interface_test.py
Normal file
@ -0,0 +1,171 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import datetime
|
||||
import sys
|
||||
|
||||
import mock
|
||||
import pykka
|
||||
|
||||
from mopidy import core, exceptions
|
||||
from mopidy.audio import PlaybackState
|
||||
from mopidy.backends import dummy
|
||||
from mopidy.models import Track
|
||||
|
||||
try:
|
||||
from mopidy.frontends.mpris import objects
|
||||
except exceptions.OptionalDependencyError:
|
||||
pass
|
||||
|
||||
from tests import unittest
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux')
|
||||
class PlayerInterfaceTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
objects.MprisObject._connect_to_dbus = mock.Mock()
|
||||
self.backend = dummy.DummyBackend.start(audio=None).proxy()
|
||||
self.core = core.Core.start(backends=[self.backend]).proxy()
|
||||
self.mpris = objects.MprisObject(core=self.core)
|
||||
|
||||
foo = self.core.playlists.create('foo').get()
|
||||
foo = foo.copy(last_modified=datetime.datetime(2012, 3, 1, 6, 0, 0))
|
||||
foo = self.core.playlists.save(foo).get()
|
||||
|
||||
bar = self.core.playlists.create('bar').get()
|
||||
bar = bar.copy(last_modified=datetime.datetime(2012, 2, 1, 6, 0, 0))
|
||||
bar = self.core.playlists.save(bar).get()
|
||||
|
||||
baz = self.core.playlists.create('baz').get()
|
||||
baz = baz.copy(last_modified=datetime.datetime(2012, 1, 1, 6, 0, 0))
|
||||
baz = self.core.playlists.save(baz).get()
|
||||
self.playlist = baz
|
||||
|
||||
def tearDown(self):
|
||||
pykka.ActorRegistry.stop_all()
|
||||
|
||||
def test_activate_playlist_appends_tracks_to_tracklist(self):
|
||||
self.core.tracklist.append([
|
||||
Track(uri='dummy:old-a'),
|
||||
Track(uri='dummy:old-b'),
|
||||
])
|
||||
self.playlist = self.playlist.copy(tracks=[
|
||||
Track(uri='dummy:baz-a'),
|
||||
Track(uri='dummy:baz-b'),
|
||||
Track(uri='dummy:baz-c'),
|
||||
])
|
||||
self.playlist = self.core.playlists.save(self.playlist).get()
|
||||
|
||||
self.assertEqual(2, self.core.tracklist.length.get())
|
||||
|
||||
playlists = self.mpris.GetPlaylists(0, 100, 'User', False)
|
||||
playlist_id = playlists[2][0]
|
||||
self.mpris.ActivatePlaylist(playlist_id)
|
||||
|
||||
self.assertEqual(5, self.core.tracklist.length.get())
|
||||
self.assertEqual(
|
||||
PlaybackState.PLAYING, self.core.playback.state.get())
|
||||
self.assertEqual(
|
||||
self.playlist.tracks[0], self.core.playback.current_track.get())
|
||||
|
||||
def test_activate_empty_playlist_is_harmless(self):
|
||||
self.assertEqual(0, self.core.tracklist.length.get())
|
||||
|
||||
playlists = self.mpris.GetPlaylists(0, 100, 'User', False)
|
||||
playlist_id = playlists[2][0]
|
||||
self.mpris.ActivatePlaylist(playlist_id)
|
||||
|
||||
self.assertEqual(0, self.core.tracklist.length.get())
|
||||
self.assertEqual(
|
||||
PlaybackState.STOPPED, self.core.playback.state.get())
|
||||
self.assertIsNone(self.core.playback.current_track.get())
|
||||
|
||||
def test_get_playlists_in_alphabetical_order(self):
|
||||
result = self.mpris.GetPlaylists(0, 100, 'Alphabetical', False)
|
||||
|
||||
self.assertEqual(3, len(result))
|
||||
|
||||
self.assertEqual('/com/mopidy/playlist/MR2W23LZHJRGC4Q_', result[0][0])
|
||||
self.assertEqual('bar', result[0][1])
|
||||
|
||||
self.assertEqual('/com/mopidy/playlist/MR2W23LZHJRGC6Q_', result[1][0])
|
||||
self.assertEqual('baz', result[1][1])
|
||||
|
||||
self.assertEqual('/com/mopidy/playlist/MR2W23LZHJTG63Y_', result[2][0])
|
||||
self.assertEqual('foo', result[2][1])
|
||||
|
||||
def test_get_playlists_in_reverse_alphabetical_order(self):
|
||||
result = self.mpris.GetPlaylists(0, 100, 'Alphabetical', True)
|
||||
|
||||
self.assertEqual(3, len(result))
|
||||
self.assertEqual('foo', result[0][1])
|
||||
self.assertEqual('baz', result[1][1])
|
||||
self.assertEqual('bar', result[2][1])
|
||||
|
||||
def test_get_playlists_in_modified_order(self):
|
||||
result = self.mpris.GetPlaylists(0, 100, 'Modified', False)
|
||||
|
||||
self.assertEqual(3, len(result))
|
||||
self.assertEqual('baz', result[0][1])
|
||||
self.assertEqual('bar', result[1][1])
|
||||
self.assertEqual('foo', result[2][1])
|
||||
|
||||
def test_get_playlists_in_reverse_modified_order(self):
|
||||
result = self.mpris.GetPlaylists(0, 100, 'Modified', True)
|
||||
|
||||
self.assertEqual(3, len(result))
|
||||
self.assertEqual('foo', result[0][1])
|
||||
self.assertEqual('bar', result[1][1])
|
||||
self.assertEqual('baz', result[2][1])
|
||||
|
||||
def test_get_playlists_in_user_order(self):
|
||||
result = self.mpris.GetPlaylists(0, 100, 'User', False)
|
||||
|
||||
self.assertEqual(3, len(result))
|
||||
self.assertEqual('foo', result[0][1])
|
||||
self.assertEqual('bar', result[1][1])
|
||||
self.assertEqual('baz', result[2][1])
|
||||
|
||||
def test_get_playlists_in_reverse_user_order(self):
|
||||
result = self.mpris.GetPlaylists(0, 100, 'User', True)
|
||||
|
||||
self.assertEqual(3, len(result))
|
||||
self.assertEqual('baz', result[0][1])
|
||||
self.assertEqual('bar', result[1][1])
|
||||
self.assertEqual('foo', result[2][1])
|
||||
|
||||
def test_get_playlists_slice_on_start_of_list(self):
|
||||
result = self.mpris.GetPlaylists(0, 2, 'User', False)
|
||||
|
||||
self.assertEqual(2, len(result))
|
||||
self.assertEqual('foo', result[0][1])
|
||||
self.assertEqual('bar', result[1][1])
|
||||
|
||||
def test_get_playlists_slice_later_in_list(self):
|
||||
result = self.mpris.GetPlaylists(2, 2, 'User', False)
|
||||
|
||||
self.assertEqual(1, len(result))
|
||||
self.assertEqual('baz', result[0][1])
|
||||
|
||||
def test_get_playlist_count_returns_number_of_playlists(self):
|
||||
result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'PlaylistCount')
|
||||
|
||||
self.assertEqual(3, result)
|
||||
|
||||
def test_get_orderings_includes_alpha_modified_and_user(self):
|
||||
result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'Orderings')
|
||||
|
||||
self.assertIn('Alphabetical', result)
|
||||
self.assertNotIn('Created', result)
|
||||
self.assertIn('Modified', result)
|
||||
self.assertNotIn('Played', result)
|
||||
self.assertIn('User', result)
|
||||
|
||||
def test_get_active_playlist_does_not_return_a_playlist(self):
|
||||
result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'ActivePlaylist')
|
||||
valid, playlist = result
|
||||
playlist_id, playlist_name, playlist_icon_uri = playlist
|
||||
|
||||
self.assertEqual(False, valid)
|
||||
self.assertEqual('/', playlist_id)
|
||||
self.assertEqual('None', playlist_name)
|
||||
self.assertEqual('', playlist_icon_uri)
|
||||
@ -31,6 +31,18 @@ class RootInterfaceTest(unittest.TestCase):
|
||||
def test_constructor_connects_to_dbus(self):
|
||||
self.assert_(self.mpris._connect_to_dbus.called)
|
||||
|
||||
def test_fullscreen_returns_false(self):
|
||||
result = self.mpris.Get(objects.ROOT_IFACE, 'Fullscreen')
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_setting_fullscreen_fails_and_returns_none(self):
|
||||
result = self.mpris.Set(objects.ROOT_IFACE, 'Fullscreen', 'True')
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_can_set_fullscreen_returns_false(self):
|
||||
result = self.mpris.Get(objects.ROOT_IFACE, 'CanSetFullscreen')
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_can_raise_returns_false(self):
|
||||
result = self.mpris.Get(objects.ROOT_IFACE, 'CanRaise')
|
||||
self.assertFalse(result)
|
||||
@ -64,7 +76,7 @@ class RootInterfaceTest(unittest.TestCase):
|
||||
self.assertEquals(result, 'foo')
|
||||
settings.runtime.clear()
|
||||
|
||||
def test_supported_uri_schemes_is_empty(self):
|
||||
def test_supported_uri_schemes_includes_backend_uri_schemes(self):
|
||||
result = self.mpris.Get(objects.ROOT_IFACE, 'SupportedUriSchemes')
|
||||
self.assertEquals(len(result), 1)
|
||||
self.assertEquals(result[0], 'dummy')
|
||||
|
||||
@ -314,21 +314,6 @@ class AlbumTest(unittest.TestCase):
|
||||
self.assertNotEqual(hash(album1), hash(album2))
|
||||
|
||||
|
||||
class TlTrackTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tlid = 123
|
||||
self.track = Track()
|
||||
self.tl_track = TlTrack(self.tlid, self.track)
|
||||
|
||||
def test_tl_track_can_be_accessed_as_a_tuple(self):
|
||||
self.assertEqual(self.tlid, self.tl_track[0])
|
||||
self.assertEqual(self.track, self.tl_track[1])
|
||||
|
||||
def test_tl_track_can_be_accessed_by_attribute_names(self):
|
||||
self.assertEqual(self.tlid, self.tl_track.tlid)
|
||||
self.assertEqual(self.track, self.tl_track.track)
|
||||
|
||||
|
||||
class TrackTest(unittest.TestCase):
|
||||
def test_uri(self):
|
||||
uri = 'an_uri'
|
||||
@ -567,6 +552,75 @@ class TrackTest(unittest.TestCase):
|
||||
self.assertNotEqual(hash(track1), hash(track2))
|
||||
|
||||
|
||||
class TlTrackTest(unittest.TestCase):
|
||||
def test_tlid(self):
|
||||
tlid = 123
|
||||
tl_track = TlTrack(tlid=tlid)
|
||||
self.assertEqual(tl_track.tlid, tlid)
|
||||
self.assertRaises(AttributeError, setattr, tl_track, 'tlid', None)
|
||||
|
||||
def test_track(self):
|
||||
track = Track()
|
||||
tl_track = TlTrack(track=track)
|
||||
self.assertEqual(tl_track.track, track)
|
||||
self.assertRaises(AttributeError, setattr, tl_track, 'track', None)
|
||||
|
||||
def test_invalid_kwarg(self):
|
||||
test = lambda: TlTrack(foo='baz')
|
||||
self.assertRaises(TypeError, test)
|
||||
|
||||
def test_positional_args(self):
|
||||
tlid = 123
|
||||
track = Track()
|
||||
tl_track = TlTrack(tlid, track)
|
||||
self.assertEqual(tl_track.tlid, tlid)
|
||||
self.assertEqual(tl_track.track, track)
|
||||
|
||||
def test_iteration(self):
|
||||
tlid = 123
|
||||
track = Track()
|
||||
tl_track = TlTrack(tlid, track)
|
||||
(tlid2, track2) = tl_track
|
||||
self.assertEqual(tlid2, tlid)
|
||||
self.assertEqual(track2, track)
|
||||
|
||||
def test_repr(self):
|
||||
self.assertEquals(
|
||||
"TlTrack(tlid=123, track=Track(artists=[], uri=u'uri'))",
|
||||
repr(TlTrack(tlid=123, track=Track(uri='uri'))))
|
||||
|
||||
def test_serialize(self):
|
||||
self.assertDictEqual(
|
||||
{'tlid': 123, 'track': {'uri': 'uri', 'name': 'name'}},
|
||||
TlTrack(tlid=123, track=Track(uri='uri', name='name')).serialize())
|
||||
|
||||
def test_eq(self):
|
||||
tlid = 123
|
||||
track = Track()
|
||||
tl_track1 = TlTrack(tlid=tlid, track=track)
|
||||
tl_track2 = TlTrack(tlid=tlid, track=track)
|
||||
self.assertEqual(tl_track1, tl_track2)
|
||||
self.assertEqual(hash(tl_track1), hash(tl_track2))
|
||||
|
||||
def test_eq_none(self):
|
||||
self.assertNotEqual(TlTrack(), None)
|
||||
|
||||
def test_eq_other(self):
|
||||
self.assertNotEqual(TlTrack(), 'other')
|
||||
|
||||
def test_ne_tlid(self):
|
||||
tl_track1 = TlTrack(tlid=123)
|
||||
tl_track2 = TlTrack(tlid=321)
|
||||
self.assertNotEqual(tl_track1, tl_track2)
|
||||
self.assertNotEqual(hash(tl_track1), hash(tl_track2))
|
||||
|
||||
def test_ne_track(self):
|
||||
tl_track1 = TlTrack(track=Track(uri='a'))
|
||||
tl_track2 = TlTrack(track=Track(uri='b'))
|
||||
self.assertNotEqual(tl_track1, tl_track2)
|
||||
self.assertNotEqual(hash(tl_track1), hash(tl_track2))
|
||||
|
||||
|
||||
class PlaylistTest(unittest.TestCase):
|
||||
def test_uri(self):
|
||||
uri = 'an_uri'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user