Merge branch 'develop' into tidy-up-core

This commit is contained in:
Javier Domingo Cansino 2013-08-08 13:19:01 +02:00
commit 694db515e4
59 changed files with 509 additions and 299 deletions

View File

@ -5,3 +5,6 @@ Kristian Klette <klette@samfundet.no>
Johannes Knutsen <johannes@knutseninfo.no> <johannes@iterate.no>
Johannes Knutsen <johannes@knutseninfo.no> <johannes@barbarmaclin.(none)>
John Bäckstrand <sopues@gmail.com> <sandos@XBMCLive.(none)>
Alli Witheford <alzeih@gmail.com>
Alexandre Petitjean <alpetitjean@gmail.com>
Alexandre Petitjean <alpetitjean@gmail.com> <alexandre.petitjean@lne.fr>

View File

@ -19,3 +19,7 @@
- Nick Steel <kingosticks@gmail.com>
- Zan Dobersek <zandobersek@gmail.com>
- Thomas Refis <refis.thomas@gmail.com>
- Janez Troha <janez.troha@gmail.com>
- Tobias Sauerwein <cgtobi@gmail.com>
- Alli Witheford <alzeih@gmail.com>
- Alexandre Petitjean <alpetitjean@gmail.com>

View File

@ -25,4 +25,5 @@ To get started with Mopidy, check out `the docs <http://docs.mopidy.com/>`_.
- Mailing list: `mopidy@googlegroups.com <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_
- Twitter: `@mopidy <https://twitter.com/mopidy/>`_
.. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop
.. image:: https://travis-ci.org/mopidy/mopidy.png?branch=develop
:target: https://travis-ci.org/mopidy/mopidy

View File

@ -22,6 +22,53 @@ v0.15.0 (UNRELEASED)
- :option:`mopidy --show-config` will now take into consideration any
:option:`mopidy --option` arguments appearing later on the command line.
**Audio**
- Added support for viusalization. :confval:`audio/visualizer` can now be set
to GStreamer visualizers.
**Local backend**
- An album's number of discs and a track's disc number are now extracted when
scanning your music collection.
- The scanner now gives up scanning a file after a second, and continues with
the next file. This fixes some hangs on non-media files, like logs. (Fixes:
:issue:`476`, :issue:`483`)
- Added support for plugable library updaters. This allows extension writers
to start providing their own custom libraries instead of being stuck with
just our tag cache as the only option.
- Converted local backend to use new `local:playlist:path` and
`local:track:path` uri scheme. Also moves support of `file://` to streaming
backend.
**Spotify backend**
- Prepend playlist folder names to the playlist name, so that the playlist
hierarchy from your Spotify account is available in Mopidy. (Fixes:
:issue:`62`)
- Fix proxy config values that was broken with the config system change in
0.14. (Fixes: :issue:`472`)
**MPD frontend**
- Replace newline, carriage return and forward slash in playlist names. (Fixes:
:issue:`474`, :issue:`480`)
v0.14.2 (2013-07-01)
====================
This is a maintenance release to make Mopidy 0.14 work with pyspotify 1.11.
**Dependencies**
- pyspotify >= 1.9, < 2 is now required for Spotify support. In other words,
you're free to upgrade to pyspotify 1.11, but it isn't a requirement.
v0.14.1 (2013-04-28)
====================

View File

@ -33,7 +33,10 @@ class Mock(object):
if name in ('__file__', '__path__'):
return '/dev/null'
elif (name[0] == name[0].upper()
and not name.startswith('MIXER_TRACK_')):
# gst.interfaces.MIXER_TRACK_*
and not name.startswith('MIXER_TRACK_')
# dbus.String()
and not name == 'String'):
return type(name, (), {})
else:
return Mock()
@ -98,7 +101,7 @@ master_doc = 'index'
# General information about the project.
project = 'Mopidy'
copyright = '2010-2013, Stein Magnus Jodal and contributors'
copyright = '2009-2013, Stein Magnus Jodal and contributors'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the

View File

@ -90,6 +90,16 @@ Core configuration values
``gst-inspect-0.10`` to see what output properties can be set on the sink.
For example: ``gst-inspect-0.10 shout2send``
.. confval:: audio/visualizer
Visualizer to use.
Can be left blank if no visualizer is desired. Otherwise this expects a
GStreamer visualizer. Typical values are ``monoscope``, ``goom``,
``goom2k1`` or one of the `libvisual`_ visualizers.
.. _libvisual: http://gstreamer.freedesktop.org/data/doc/gstreamer/head/gst-plugins-base-plugins/html/gst-plugins-base-plugins-plugin-libvisual.html
.. confval:: logging/console_format
The log format used for informational logging.
@ -137,6 +147,24 @@ Core configuration values
.. _the Python logging docs: http://docs.python.org/2/library/logging.config.html
Extension configuration
=======================
Mopidy's extensions have their own config values that you may want to tweak.
For the available config values, please refer to the docs for each extension.
Most, if not all, can be found at :ref:`ext`.
Mopidy extensions are enabled by default when they are installed. If you want
to disable an extension without uninstalling it, all extensions support the
``enabled`` config value even if it isn't explicitly documented by all
extensions. If the ``enabled`` config value is set to ``false`` the extension
will not be started. For example, to disable the Spotify extension, add the
following to your ``mopidy.conf``::
[spotify]
enabled = false
Extension configuration
=======================

View File

@ -47,6 +47,11 @@ Configuration values
Path to tag cache for local media.
.. confval:: local/scan_timeout
Number of milliseconds before giving up scanning a file and moving on to
the next file.
Usage
=====

View File

@ -48,7 +48,7 @@ About
:maxdepth: 1
authors
licenses
license
changelog
versioning

View File

@ -16,12 +16,20 @@ distribution.
.. _raspi-wheezy:
How to for Debian 7 (Wheezy)
============================
How to for Raspbian "wheezy" and Debian "wheezy"
================================================
#. Download the latest wheezy disk image from
http://downloads.raspberrypi.org/images/debian/7/. I used the one dated
2012-08-08.
This guide applies for both:
- Raspian "wheezy" for armhf (hard-float), and
- Debian "wheezy" for armel (soft-float)
If you don't know which one to select, go for the armhf variant, as it'll give
you a lot better performance.
#. Download the latest "wheezy" disk image from
http://www.raspberrypi.org/downloads/. This was last tested with the images
from 2013-05-25 for armhf and 2013-05-29 for armel.
#. Flash the OS image to your SD card. See
http://elinux.org/RPi_Easy_SD_Card_Setup for help.
@ -82,10 +90,10 @@ card.
#. Ensure your system is up to date. On Debian based systems run::
sudo apt-get update
sudo apt-get full-upgrade
sudo apt-get dist-upgrade
#. Ensure you have a new enough firmware. On Debian based systems
`rpi-update <http://apt.mopidy.com://github.com/Hexxeh/rpi-update>`_
`rpi-update <https://github.com/Hexxeh/rpi-update>`_
can be used.
#. Update either ``~/.asoundrc`` or ``/etc/asound.conf`` to the

10
docs/license.rst Normal file
View File

@ -0,0 +1,10 @@
*******
License
*******
Mopidy is copyright 2009-2013 Stein Magnus Jodal and contributors. For a list
of contributors, see :doc:`authors`. For details on who have contributed what,
please refer to our git repository.
Mopidy is licensed under the `Apache License, Version 2.0
<http://www.apache.org/licenses/LICENSE-2.0>`_.

View File

@ -1,34 +0,0 @@
********
Licenses
********
For a list of contributors, see :doc:`authors`. For details on who have
contributed what, please refer to our git repository.
Source code license
===================
Copyright 2009-2013 Stein Magnus Jodal and contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Documentation license
=====================
Copyright 2010-2013 Stein Magnus Jodal and contributors
This work is licensed under the Creative Commons Attribution-ShareAlike 3.0
Unported License. To view a copy of this license, visit
http://creativecommons.org/licenses/by-sa/3.0/ or send a letter to Creative
Commons, 171 Second Street, Suite 300, San Francisco, California, 94105, USA.

36
fabfile.py vendored
View File

@ -1,21 +1,51 @@
from fabric.api import local, settings
from fabric.api import execute, local, settings, task
@task
def docs():
local('make -C docs/ html')
@task
def autodocs():
auto(docs)
@task
def test(path=None):
path = path or 'tests/'
local('nosetests ' + path)
@task
def autotest(path=None):
auto(test, path=path)
@task
def coverage(path=None):
path = path or 'tests/'
local(
'nosetests --with-coverage --cover-package=mopidy '
'--cover-branches --cover-html ' + path)
@task
def autocoverage(path=None):
auto(coverage, path=path)
def auto(task, *args, **kwargs):
while True:
local('clear')
with settings(warn_only=True):
test(path)
execute(task, *args, **kwargs)
local(
'inotifywait -q -e create -e modify -e delete '
'--exclude ".*\.(pyc|sw.)" -r mopidy/ tests/')
'--exclude ".*\.(pyc|sw.)" -r docs/ mopidy/ tests/')
@task
def update_authors():
# Keep authors in the order of appearance and use awk to filter out dupes
local(

View File

@ -51,15 +51,15 @@ Building from source
1. Install [Node.js](http://nodejs.org/) and npm. There is a PPA if you're
running Ubuntu:
sudo apt-get install python-software-properties
sudo add-apt-repository ppa:chris-lea/node.js
sudo apt-get update
sudo apt-get install nodejs npm
sudo apt-get install python-software-properties
sudo add-apt-repository ppa:chris-lea/node.js
sudo apt-get update
sudo apt-get install nodejs
2. Enter the `js/` in Mopidy's Git repo dir and install all dependencies:
cd js/
npm install
cd js/
npm install
That's it.

View File

@ -23,4 +23,4 @@ if (isinstance(pykka.__version__, basestring)
warnings.filterwarnings('ignore', 'could not open display')
__version__ = '0.14.1'
__version__ = '0.14.2'

View File

@ -22,6 +22,22 @@ mixers.register_mixers()
MB = 1 << 20
# GST_PLAY_FLAG_VIDEO (1<<0)
# GST_PLAY_FLAG_AUDIO (1<<1)
# GST_PLAY_FLAG_TEXT (1<<2)
# GST_PLAY_FLAG_VIS (1<<3)
# GST_PLAY_FLAG_SOFT_VOLUME (1<<4)
# GST_PLAY_FLAG_NATIVE_AUDIO (1<<5)
# GST_PLAY_FLAG_NATIVE_VIDEO (1<<6)
# GST_PLAY_FLAG_DOWNLOAD (1<<7)
# GST_PLAY_FLAG_BUFFERING (1<<8)
# GST_PLAY_FLAG_DEINTERLACE (1<<9)
# GST_PLAY_FLAG_SOFT_COLORBALANCE (1<<10)
# Default flags to use for playbin: AUDIO, SOFT_VOLUME, DOWNLOAD
PLAYBIN_FLAGS = (1<<1) | (1<<4) | (1<<7)
PLAYBIN_VIS_FLAGS = PLAYBIN_FLAGS | (1<<3)
class Audio(pykka.ThreadingActor):
"""
@ -55,6 +71,7 @@ class Audio(pykka.ThreadingActor):
try:
self._setup_playbin()
self._setup_output()
self._setup_visualizer()
self._setup_mixer()
self._setup_message_processor()
except gobject.GError as ex:
@ -78,9 +95,7 @@ class Audio(pykka.ThreadingActor):
def _setup_playbin(self):
playbin = gst.element_factory_make('playbin2')
fakesink = gst.element_factory_make('fakesink')
playbin.set_property('video-sink', fakesink)
playbin.set_property('flags', PLAYBIN_FLAGS)
self._connect(playbin, 'about-to-finish', self._on_about_to_finish)
self._connect(playbin, 'notify::source', self._on_new_source)
@ -149,6 +164,19 @@ class Audio(pykka.ThreadingActor):
'Failed to create audio output "%s": %s', output_desc, ex)
process.exit_process()
def _setup_visualizer(self):
visualizer_element = self._config['audio']['visualizer']
if not visualizer_element:
return
try:
visualizer = gst.element_factory_make(visualizer_element)
self._playbin.set_property('vis-plugin', visualizer)
self._playbin.set_property('flags', PLAYBIN_VIS_FLAGS)
logger.info('Audio visualizer set to "%s"', visualizer_element)
except gobject.GError as ex:
logger.error(
'Failed to create audio visualizer "%s": %s', visualizer_element, ex)
def _setup_mixer(self):
mixer_desc = self._config['audio']['mixer']
track_desc = self._config['audio']['mixer_track']

View File

@ -15,11 +15,6 @@ class Backend(object):
#: the backend doesn't provide a library.
library = None
#: The library update provider. An instance of
#: :class:`~mopidy.backends.base.BaseLibraryUpdateProvider`, or
#: :class:`None` if the backend doesn't provide a library.
updater = None
#: The playback provider. An instance of
#: :class:`~mopidy.backends.base.BasePlaybackProvider`, or :class:`None` if
#: the backend doesn't provide playback.
@ -40,9 +35,6 @@ class Backend(object):
def has_library(self):
return self.library is not None
def has_updater(self):
return self.updater is not None
def has_playback(self):
return self.playback is not None
@ -96,15 +88,7 @@ class BaseLibraryProvider(object):
class BaseLibraryUpdateProvider(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backends.base.Backend`
"""
pykka_traversable = True
def __init__(self, backend):
self.backend = backend
uri_schemes = []
def load(self):
"""Loads the library and returns all tracks in it.
@ -172,9 +156,22 @@ class BasePlaybackProvider(object):
:rtype: :class:`True` if successful, else :class:`False`
"""
self.audio.prepare_change()
self.audio.set_uri(track.uri).get()
self.change_track(track)
return self.audio.start_playback().get()
def change_track(self, track):
"""
Swith to provided track.
*MAY be reimplemented by subclass.*
:param track: the track to play
:type track: :class:`mopidy.models.Track`
:rtype: :class:`True` if successful, else :class:`False`
"""
self.audio.set_uri(track.uri).get()
return True
def resume(self):
"""
Resume playback at the same time position playback was paused.

View File

@ -21,6 +21,7 @@ class Extension(ext.Extension):
schema['media_dir'] = config.Path()
schema['playlists_dir'] = config.Path()
schema['tag_cache_file'] = config.Path()
schema['scan_timeout'] = config.Integer(minimum=0)
return schema
def validate_environment(self):
@ -29,3 +30,7 @@ class Extension(ext.Extension):
def get_backend_classes(self):
from .actor import LocalBackend
return [LocalBackend]
def get_library_updaters(self):
from .library import LocalLibraryUpdateProvider
return [LocalLibraryUpdateProvider]

View File

@ -8,8 +8,9 @@ import pykka
from mopidy.backends import base
from mopidy.utils import encoding, path
from .library import LocalLibraryProvider, LocalLibraryUpdateProvider
from .library import LocalLibraryProvider
from .playlists import LocalPlaylistsProvider
from .playback import LocalPlaybackProvider
logger = logging.getLogger('mopidy.backends.local')
@ -23,11 +24,10 @@ class LocalBackend(pykka.ThreadingActor, base.Backend):
self.check_dirs_and_files()
self.library = LocalLibraryProvider(backend=self)
self.updater = LocalLibraryUpdateProvider(backend=self)
self.playback = base.BasePlaybackProvider(audio=audio, backend=self)
self.playback = LocalPlaybackProvider(audio=audio, backend=self)
self.playlists = LocalPlaylistsProvider(backend=self)
self.uri_schemes = ['file']
self.uri_schemes = ['local']
def check_dirs_and_files(self):
if not os.path.isdir(self.config['local']['media_dir']):

View File

@ -3,3 +3,4 @@ enabled = true
media_dir = $XDG_MUSIC_DIR
playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists
tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache
scan_timeout = 1000

View File

@ -81,7 +81,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
result_tracks = filter(any_filter, result_tracks)
else:
raise LookupError('Invalid lookup field: %s' % field)
return SearchResult(uri='file:search', tracks=result_tracks)
# TODO: add local:search:<query>
return SearchResult(uri='local:search', tracks=result_tracks)
def search(self, query=None, uris=None):
# TODO Only return results within URI roots given by ``uris``
@ -122,7 +123,8 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
result_tracks = filter(any_filter, result_tracks)
else:
raise LookupError('Invalid lookup field: %s' % field)
return SearchResult(uri='file:search', tracks=result_tracks)
# TODO: add local:search:<query>
return SearchResult(uri='local:search', tracks=result_tracks)
def _validate_query(self, query):
for (_, values) in query.iteritems():
@ -135,11 +137,12 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
# TODO: rename and move to tagcache extension.
class LocalLibraryUpdateProvider(base.BaseLibraryProvider):
def __init__(self, *args, **kwargs):
super(LocalLibraryUpdateProvider, self).__init__(*args, **kwargs)
uri_schemes = ['local']
def __init__(self, config):
self._tracks = {}
self._media_dir = self.backend.config['local']['media_dir']
self._tag_cache_file = self.backend.config['local']['tag_cache_file']
self._media_dir = config['local']['media_dir']
self._tag_cache_file = config['local']['tag_cache_file']
def load(self):
tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir)
@ -156,6 +159,8 @@ class LocalLibraryUpdateProvider(base.BaseLibraryProvider):
def commit(self):
directory, basename = os.path.split(self._tag_cache_file)
# TODO: cleanup directory/basename.* files.
tmp = tempfile.NamedTemporaryFile(
prefix=basename + '.', dir=directory, delete=False)

View File

@ -0,0 +1,19 @@
from __future__ import unicode_literals
import logging
import os
from mopidy.backends import base
from mopidy.utils import path
logger = logging.getLogger('mopidy.backends.local')
class LocalPlaybackProvider(base.BasePlaybackProvider):
def change_track(self, track):
media_dir = self.backend.config['local']['media_dir']
# TODO: check that type is correct.
file_path = path.uri_to_path(track.uri).split(':', 1)[1]
file_path = os.path.join(media_dir, file_path)
track = track.copy(uri=path.path_to_uri(file_path))
return super(LocalPlaybackProvider, self).change_track(track)

View File

@ -24,7 +24,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider):
def create(self, name):
name = formatting.slugify(name)
uri = path.path_to_uri(self._get_m3u_path(name))
uri = 'local:playlist:%s.m3u' % name
playlist = Playlist(uri=uri, name=name)
return self.save(playlist)
@ -37,6 +37,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider):
self._delete_m3u(playlist.uri)
def lookup(self, uri):
# TODO: store as {uri: playlist}?
for playlist in self._playlists:
if playlist.uri == uri:
return playlist
@ -45,8 +46,8 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider):
playlists = []
for m3u in glob.glob(os.path.join(self._playlists_dir, '*.m3u')):
uri = path.path_to_uri(m3u)
name = os.path.splitext(os.path.basename(m3u))[0]
uri = 'local:playlist:%s' % name
tracks = []
for track_uri in parse_m3u(m3u, self._media_dir):
@ -61,6 +62,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider):
playlists.append(playlist)
self.playlists = playlists
# TODO: send what scheme we loaded them for?
listener.BackendListener.send('playlists_loaded')
logger.info(
@ -86,38 +88,30 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider):
return playlist
def _get_m3u_path(self, name):
name = formatting.slugify(name)
file_path = os.path.join(self._playlists_dir, name + '.m3u')
def _m3u_uri_to_path(self, uri):
# TODO: create uri handling helpers for local uri types.
file_path = path.uri_to_path(uri).split(':', 1)[1]
file_path = os.path.join(self._playlists_dir, file_path)
path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir)
return file_path
def _save_m3u(self, playlist):
file_path = path.uri_to_path(playlist.uri)
path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir)
file_path = self._m3u_uri_to_path(playlist.uri)
with open(file_path, 'w') as file_handle:
for track in playlist.tracks:
if track.uri.startswith('file://'):
uri = path.uri_to_path(track.uri)
else:
uri = track.uri
file_handle.write(uri + '\n')
file_handle.write(track.uri + '\n')
def _delete_m3u(self, uri):
file_path = path.uri_to_path(uri)
path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir)
file_path = self._m3u_uri_to_path(uri)
if os.path.exists(file_path):
os.remove(file_path)
def _rename_m3u(self, playlist):
src_file_path = path.uri_to_path(playlist.uri)
path.check_file_path_is_inside_base_dir(
src_file_path, self._playlists_dir)
dst_name = formatting.slugify(playlist.name)
dst_uri = 'local:playlist:%s.m3u' % dst_name
dst_file_path = self._get_m3u_path(playlist.name)
path.check_file_path_is_inside_base_dir(
dst_file_path, self._playlists_dir)
src_file_path = self._m3u_uri_to_path(playlist.uri)
dst_file_path = self._m3u_uri_to_path(dst_uri)
shutil.move(src_file_path, dst_file_path)
return playlist.copy(uri=path.path_to_uri(dst_file_path))
return playlist.copy(uri=dst_uri)

View File

@ -1,7 +1,9 @@
from __future__ import unicode_literals
import logging
import os
import urllib
import urlparse
from mopidy.models import Track, Artist, Album
from mopidy.utils.encoding import locale_decode
@ -30,7 +32,6 @@ def parse_m3u(file_path, media_dir):
- m3u files are latin-1.
- This function does not bother with Extended M3U directives.
"""
# TODO: uris as bytes
uris = []
try:
@ -46,16 +47,19 @@ def parse_m3u(file_path, media_dir):
if line.startswith('#'):
continue
# FIXME what about other URI types?
if line.startswith('file://'):
if urlparse.urlsplit(line).scheme:
uris.append(line)
elif os.path.normpath(line) == os.path.abspath(line):
path = path_to_uri(line)
uris.append(path)
else:
path = path_to_uri(media_dir, line)
path = path_to_uri(os.path.join(media_dir, line))
uris.append(path)
return uris
# TODO: remove music_dir from API
def parse_mpd_tag_cache(tag_cache, music_dir=''):
"""
Converts a MPD tag_cache into a lists of tracks, artists and albums.
@ -86,17 +90,17 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''):
key, value = line.split(b': ', 1)
if key == b'key':
_convert_mpd_data(current, tracks, music_dir)
_convert_mpd_data(current, tracks)
current.clear()
current[key.lower()] = value.decode('utf-8')
_convert_mpd_data(current, tracks, music_dir)
_convert_mpd_data(current, tracks)
return tracks
def _convert_mpd_data(data, tracks, music_dir):
def _convert_mpd_data(data, tracks):
if not data:
return
@ -160,15 +164,8 @@ def _convert_mpd_data(data, tracks, music_dir):
path = data['file'][1:]
else:
path = data['file']
path = urllib.unquote(path.encode('utf-8'))
if isinstance(music_dir, unicode):
music_dir = music_dir.encode('utf-8')
# Make sure we only pass bytestrings to path_to_uri to avoid implicit
# decoding of bytestrings to unicode strings
track_kwargs['uri'] = path_to_uri(music_dir, path)
track_kwargs['uri'] = 'local:track:%s' % path
track_kwargs['length'] = int(data.get('time', 0)) * 1000
track = Track(**track_kwargs)

View File

@ -33,9 +33,17 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
self.cache_location = config['spotify']['cache_dir']
self.settings_location = config['spotify']['cache_dir']
full_proxy = ''
if config['proxy']['hostname']:
full_proxy = config['proxy']['hostname']
if config['proxy']['port']:
full_proxy += ':' + str(config['proxy']['port'])
if config['proxy']['scheme']:
full_proxy = config['proxy']['scheme'] + "://" + full_proxy
PyspotifySessionManager.__init__(
self, config['spotify']['username'], config['spotify']['password'],
proxy=config['proxy']['hostname'],
proxy=full_proxy,
proxy_username=config['proxy']['username'],
proxy_password=config['proxy']['password'])
@ -173,9 +181,14 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
logger.debug('Still getting data; skipped refresh of playlists')
return
playlists = []
folders = []
for spotify_playlist in self.session.playlist_container():
if spotify_playlist.type() == 'folder_start':
folders.append(spotify_playlist)
if spotify_playlist.type() == 'folder_end':
folders.pop()
playlists.append(translator.to_mopidy_playlist(
spotify_playlist,
spotify_playlist, folders=folders,
bitrate=self.bitrate, username=self.username))
playlists.append(translator.to_mopidy_playlist(
self.session.starred(),

View File

@ -67,7 +67,7 @@ def to_mopidy_track(spotify_track, bitrate=None):
return track_cache[uri]
def to_mopidy_playlist(spotify_playlist, bitrate=None, username=None):
def to_mopidy_playlist(spotify_playlist, folders=None, bitrate=None, username=None):
if spotify_playlist is None or spotify_playlist.type() != 'playlist':
return
try:
@ -78,6 +78,9 @@ def to_mopidy_playlist(spotify_playlist, bitrate=None, username=None):
if not spotify_playlist.is_loaded():
return Playlist(uri=uri, name='[loading...]')
name = spotify_playlist.name()
if folders:
folder_names = '/'.join(folder.name() for folder in folders)
name = folder_names + '/' + name
tracks = [
to_mopidy_track(spotify_track, bitrate=bitrate)
for spotify_track in spotify_playlist

View File

@ -1,6 +1,7 @@
[stream]
enabled = true
protocols =
file
http
https
mms

View File

@ -24,9 +24,13 @@ _audio_schema = ConfigSchema('audio')
_audio_schema['mixer'] = String()
_audio_schema['mixer_track'] = String(optional=True)
_audio_schema['output'] = String()
_audio_schema['visualizer'] = String(optional=True)
_proxy_schema = ConfigSchema('proxy')
_proxy_schema['scheme'] = String(optional=True,
choices=['http', 'https', 'socks4', 'socks5'])
_proxy_schema['hostname'] = Hostname(optional=True)
_proxy_schema['port'] = Port(optional=True)
_proxy_schema['username'] = String(optional=True)
_proxy_schema['password'] = Secret(optional=True)

View File

@ -39,6 +39,7 @@ def convert(settings):
helper('audio/output', 'OUTPUT')
helper('proxy/hostname', 'SPOTIFY_PROXY_HOST')
helper('proxy/port', 'SPOTIFY_PROXY_PORT')
helper('proxy/username', 'SPOTIFY_PROXY_USERNAME')
helper('proxy/password', 'SPOTIFY_PROXY_PASSWORD')

View File

@ -11,8 +11,11 @@ pykka = info
mixer = autoaudiomixer
mixer_track =
output = autoaudiosink
visualizer =
[proxy]
scheme =
hostname =
port =
username =
password =

View File

@ -3,7 +3,6 @@ from __future__ import unicode_literals
import logging
import re
import socket
import sys
from mopidy.utils import path
from mopidy.config import validators
@ -72,9 +71,9 @@ class String(ConfigValue):
def deserialize(self, value):
value = decode(value).strip()
validators.validate_required(value, self._required)
validators.validate_choice(value, self._choices)
if not value:
return None
validators.validate_choice(value, self._choices)
return value
def serialize(self, value, display=False):
@ -112,12 +111,16 @@ class Secret(ConfigValue):
class Integer(ConfigValue):
"""Integer value."""
def __init__(self, minimum=None, maximum=None, choices=None):
def __init__(self, minimum=None, maximum=None, choices=None, optional=False):
self._required = not optional
self._minimum = minimum
self._maximum = maximum
self._choices = choices
def deserialize(self, value):
validators.validate_required(value, self._required)
if not value:
return None
value = int(value)
validators.validate_choice(value, self._choices)
validators.validate_minimum(value, self._minimum)
@ -223,8 +226,9 @@ class Port(Integer):
allocate a port for us.
"""
# TODO: consider probing if port is free or not?
def __init__(self, choices=None):
super(Port, self).__init__(minimum=0, maximum=2**16-1, choices=choices)
def __init__(self, choices=None, optional=False):
super(Port, self).__init__(
minimum=0, maximum=2 ** 16 - 1, choices=choices, optional=optional)
class Path(ConfigValue):
@ -256,7 +260,7 @@ class Path(ConfigValue):
def serialize(self, value, display=False):
if isinstance(value, unicode):
value = value.encode(sys.getfilesystemencoding())
raise ValueError('paths should always be bytes')
if isinstance(value, ExpandedPath):
return value.original
return value

View File

@ -215,6 +215,7 @@ class PlaybackController(object):
logger.warning('Track is not playable: %s', tl_track.track.uri)
self.core.tracklist.mark("unplayable", tl_track)
if on_error_step == 1:
# TODO: can cause an endless loop for single track repeat.
self.next()
elif on_error_step == -1:
self.previous()

View File

@ -79,6 +79,14 @@ class Extension(object):
"""
return []
def get_library_updaters(self):
"""List of library updater classes
:returns: list of :class:`~mopidy.backends.base.BaseLibraryUpdateProvider`
subclasses
"""
return []
def register_gstreamer_elements(self):
"""Hook for registering custom GStreamer elements

View File

@ -236,6 +236,8 @@ class MpdContext(object):
#: The subsytems that we want to be notified about in idle mode.
subscriptions = None
_invalid_playlist_chars = re.compile(r'[\n\r/]')
def __init__(self, dispatcher, session=None, config=None, core=None):
self.dispatcher = dispatcher
self.session = session
@ -248,10 +250,11 @@ class MpdContext(object):
self.refresh_playlists_mapping()
def create_unique_name(self, playlist_name):
name = playlist_name
stripped_name = self._invalid_playlist_chars.sub(' ', playlist_name)
name = stripped_name
i = 2
while name in self._playlist_uri_from_name:
name = '%s [%d]' % (playlist_name, i)
name = '%s [%d]' % (stripped_name, i)
i += 1
return name
@ -266,6 +269,7 @@ class MpdContext(object):
for playlist in self.core.playlists.playlists.get():
if not playlist.name:
continue
# TODO: add scheme to name perhaps 'foo (spotify)' etc.
name = self.create_unique_name(playlist.name)
self._playlist_uri_from_name[name] = playlist.uri
self._playlist_name_from_uri[playlist.uri] = name

View File

@ -39,8 +39,8 @@ def _artist_as_track(artist):
artists=[artist])
@handle_request(r'^count "(?P<tag>[^"]+)" "(?P<needle>[^"]*)"$')
def count(context, tag, needle):
@handle_request(r'^count ' + QUERY_RE)
def count(context, mpd_query):
"""
*musicpd.org, music database section:*
@ -48,6 +48,11 @@ def count(context, tag, needle):
Counts the number of songs and their total playtime in the db
matching ``TAG`` exactly.
*GMPC:*
- does not add quotes around the tag argument.
- use multiple tag-needle pairs to make more specific searches.
"""
return [('songs', 0), ('playtime', 0)] # TODO

View File

@ -156,14 +156,14 @@ def query_from_mpd_list_format(field, mpd_query):
if field == 'album':
if not tokens[0]:
raise ValueError
return {'artist': [tokens[0]]} # See above NOTE
return {'artist': [tokens[0]]}
else:
raise MpdArgError(
'should be "Album" for 3 arguments', command='list')
elif len(tokens) % 2 == 0:
query = {}
while tokens:
key = str(tokens[0].lower()) # See above NOTE
key = tokens[0].lower()
value = tokens[1]
tokens = tokens[2:]
if key not in ('artist', 'album', 'date', 'genre'):

View File

@ -34,7 +34,7 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener):
self.mpris_object = objects.MprisObject(self.config, self.core)
self._send_startup_notification()
except Exception as e:
logger.error('MPRIS frontend setup failed (%s)', e)
logger.warning('MPRIS frontend setup failed (%s)', e)
self.stop()
def on_stop(self):

View File

@ -27,7 +27,6 @@ pygst.require('0.10')
import gst
from mopidy import config as config_lib, ext
from mopidy.audio import dummy as dummy_audio
from mopidy.models import Track, Artist, Album
from mopidy.utils import log, path, versioning
@ -45,21 +44,36 @@ def main():
log.setup_root_logger()
log.setup_console_logging(logging_config, args.verbosity_level)
extensions = dict((e.ext_name, e) for e in ext.load_extensions())
extensions = ext.load_extensions()
config, errors = config_lib.load(
config_files, extensions.values(), config_overrides)
config_files, extensions, config_overrides)
log.setup_log_levels(config)
if not config['local']['media_dir']:
logging.warning('Config value local/media_dir is not set.')
return
if not config['local']['scan_timeout']:
logging.warning('Config value local/scan_timeout is not set.')
return
# TODO: missing config error checking and other default setup code.
audio = dummy_audio.DummyAudio()
local_backend_classes = extensions['local'].get_backend_classes()
local_backend = local_backend_classes[0](config, audio)
local_updater = local_backend.updater
updaters = {}
for e in extensions:
for updater_class in e.get_library_updaters():
if updater_class and 'local' in updater_class.uri_schemes:
updaters[e.ext_name] = updater_class
if not updaters:
logging.error('No usable library updaters found.')
return
elif len(updaters) > 1:
logging.error('More than one library updater found. '
'Provided by: %s', ', '.join(updaters.keys()))
return
local_updater = updaters.values()[0](config) # TODO: switch to actor?
media_dir = config['local']['media_dir']
@ -97,9 +111,11 @@ def main():
logging.warning('Failed %s: %s', uri, error)
logging.debug('Debug info for %s: %s', uri, debug)
scan_timeout = config['local']['scan_timeout']
logging.info('Scanning new and modified tracks.')
# TODO: just pass the library in instead?
scanner = Scanner(uris_update, store, debug)
scanner = Scanner(uris_update, store, debug, scan_timeout)
try:
scanner.start()
except KeyboardInterrupt:
@ -139,6 +155,7 @@ def translator(data):
_retrieve(gst.TAG_ALBUM, 'name', album_kwargs)
_retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs)
_retrieve(gst.TAG_ALBUM_VOLUME_COUNT, 'num_discs', album_kwargs)
_retrieve(gst.TAG_ARTIST, 'name', artist_kwargs)
if gst.TAG_DATE in data and data[gst.TAG_DATE]:
@ -152,6 +169,7 @@ def translator(data):
_retrieve(gst.TAG_TITLE, 'name', track_kwargs)
_retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs)
_retrieve(gst.TAG_ALBUM_VOLUME_NUMBER, 'disc_no', track_kwargs)
# Following keys don't seem to have TAG_* constant.
_retrieve('album-artist', 'name', albumartist_kwargs)
@ -174,12 +192,14 @@ def translator(data):
class Scanner(object):
def __init__(self, uris, data_callback, error_callback=None):
def __init__(self, uris, data_callback, error_callback=None, scan_timeout=1000):
self.data = {}
self.uris = iter(uris)
self.data_callback = data_callback
self.error_callback = error_callback
self.scan_timeout = scan_timeout
self.loop = gobject.MainLoop()
self.timeout_id = None
self.fakesink = gst.element_factory_make('fakesink')
self.fakesink.set_property('signal-handoffs', True)
@ -250,6 +270,14 @@ class Scanner(object):
self.error_callback(uri, error, debug)
self.next_uri()
def process_timeout(self):
if self.error_callback:
uri = self.uribin.get_property('uri')
self.error_callback(
uri, 'Scan timed out after %d ms' % self.scan_timeout, None)
self.next_uri()
return False
def get_duration(self):
self.pipe.get_state() # Block until state change is done.
try:
@ -260,6 +288,9 @@ class Scanner(object):
def next_uri(self):
self.data = {}
if self.timeout_id:
gobject.source_remove(self.timeout_id)
self.timeout_id = None
try:
uri = next(self.uris)
except StopIteration:
@ -267,6 +298,7 @@ class Scanner(object):
return False
self.pipe.set_state(gst.STATE_NULL)
self.uribin.set_property('uri', uri)
self.timeout_id = gobject.timeout_add(self.scan_timeout, self.process_timeout)
self.pipe.set_state(gst.STATE_PLAYING)
return True

View File

@ -2,12 +2,11 @@ from __future__ import unicode_literals
import logging
import os
import re
# pylint: disable = W0402
import string
# pylint: enable = W0402
import sys
import urllib
import urlparse
import glib
@ -51,7 +50,7 @@ def get_or_create_file(file_path):
return file_path
def path_to_uri(*paths):
def path_to_uri(path):
"""
Convert OS specific path to file:// URI.
@ -61,17 +60,15 @@ def path_to_uri(*paths):
Returns a file:// URI as an unicode string.
"""
path = os.path.join(*paths)
if isinstance(path, unicode):
path = path.encode('utf-8')
if sys.platform == 'win32':
return 'file:' + urllib.quote(path)
return 'file://' + urllib.quote(path)
path = urllib.quote(path)
return urlparse.urlunsplit((b'file', b'', path, b'', b''))
def uri_to_path(uri):
"""
Convert the file:// to a OS specific path.
Convert an URI to a OS specific path.
Returns a bytestring, since the file path can contain chars with other
encoding than UTF-8.
@ -82,10 +79,7 @@ def uri_to_path(uri):
"""
if isinstance(uri, unicode):
uri = uri.encode('utf-8')
if sys.platform == 'win32':
return urllib.unquote(re.sub(b'^file:', b'', uri))
else:
return urllib.unquote(re.sub(b'^file://', b'', uri))
return urllib.unquote(urlparse.urlsplit(uri).path)
def split_path(path):

View File

@ -1,2 +0,0 @@
pyserial
# Available as python-serial in Debian/Ubuntu

View File

@ -2,4 +2,5 @@ cherrypy >= 3.2.2
# Available as python-cherrypy3 in Debian/Ubuntu
ws4py >= 0.2.3
# Available as python-ws4py from apt.mopidy.com
# Available as python-ws4py in newer Debian/Ubuntu and from apt.mopidy.com for
# older releases of Debian/Ubuntu

View File

@ -1,4 +1,4 @@
pyspotify >= 1.9, < 1.11
pyspotify >= 1.9, < 2
# The libspotify Python wrapper
# Available as the python-spotify package from apt.mopidy.com

View File

@ -1,6 +0,0 @@
[nosetests]
verbosity = 1
#with-coverage = 1
cover-package = mopidy
cover-inclusive = 1
cover-html = 1

View File

@ -28,7 +28,7 @@ setup(
'Pykka >= 1.1',
],
extras_require={
'spotify': ['pyspotify >= 1.9, < 1.11'],
'spotify': ['pyspotify >= 1.9, < 2'],
'scrobbler': ['pylast >= 0.5.7'],
'http': ['cherrypy >= 3.2.2', 'ws4py >= 0.2.3'],
},
@ -36,7 +36,6 @@ setup(
tests_require=[
'nose',
'mock >= 1.0',
'unittest2',
],
entry_points={
'console_scripts': [
@ -61,7 +60,6 @@ setup(
'License :: OSI Approved :: Apache Software License',
'Operating System :: MacOS :: MacOS X',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Topic :: Multimedia :: Sound/Audio :: Players',
],

View File

@ -1,12 +1,11 @@
from __future__ import unicode_literals
import os
import sys
def path_to_data_dir(name):
if not isinstance(name, bytes):
name = name.encode(sys.getfilesystemencoding())
name = name.encode('utf-8')
path = os.path.dirname(__file__)
path = os.path.join(path, b'data')
path = os.path.abspath(path)

View File

@ -21,6 +21,7 @@ class AudioTest(unittest.TestCase):
'mixer': 'fakemixer track_max_volume=65536',
'mixer_track': None,
'output': 'fakesink',
'visualizer': None,
}
}
self.song_uri = path_to_uri(path_to_data_dir('song1.wav'))
@ -70,6 +71,7 @@ class AudioTest(unittest.TestCase):
'mixer': 'fakemixer track_max_volume=40',
'mixer_track': None,
'output': 'fakesink',
'visualizer': None,
}
}
self.audio = audio.Audio.start(config=config).proxy()

View File

@ -7,8 +7,6 @@ import pykka
from mopidy import core
from mopidy.models import Track, Album, Artist
from tests import path_to_data_dir
class LibraryControllerTest(object):
artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()]
@ -17,13 +15,10 @@ class LibraryControllerTest(object):
Album(name='album2', artists=artists[1:2]),
Album()]
tracks = [
Track(
uri='file://' + path_to_data_dir('uri1'), name='track1',
artists=artists[:1], album=albums[0], date='2001-02-03',
length=4000),
Track(
uri='file://' + path_to_data_dir('uri2'), name='track2',
artists=artists[1:2], album=albums[1], date='2002', length=4000),
Track(uri='local:track:path1', name='track1', artists=artists[:1],
album=albums[0], date='2001-02-03', length=4000),
Track(uri='local:track:path2', name='track2', artists=artists[1:2],
album=albums[1], date='2002', length=4000),
Track()]
config = {}
@ -66,11 +61,11 @@ class LibraryControllerTest(object):
self.assertEqual(list(result[0].tracks), [])
def test_find_exact_uri(self):
track_1_uri = 'file://' + path_to_data_dir('uri1')
track_1_uri = 'local:track:path1'
result = self.library.find_exact(uri=track_1_uri)
self.assertEqual(list(result[0].tracks), self.tracks[:1])
track_2_uri = 'file://' + path_to_data_dir('uri2')
track_2_uri = 'local:track:path2'
result = self.library.find_exact(uri=track_2_uri)
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
@ -136,10 +131,10 @@ class LibraryControllerTest(object):
self.assertEqual(list(result[0].tracks), [])
def test_search_uri(self):
result = self.library.search(uri=['RI1'])
result = self.library.search(uri=['TH1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.search(uri=['RI2'])
result = self.library.search(uri=['TH2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_search_track(self):
@ -183,7 +178,7 @@ class LibraryControllerTest(object):
self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.search(any=['Bum1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.search(any=['RI1'])
result = self.library.search(any=['TH1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
def test_search_wrong_type(self):

View File

@ -1,9 +1,4 @@
from __future__ import unicode_literals
from mopidy.utils.path import path_to_uri
from tests import path_to_data_dir
song = path_to_data_dir('song%s.wav')
generate_song = lambda i: path_to_uri(song % i)
generate_song = lambda i: 'local:track:song%s.wav' % i

View File

@ -5,7 +5,6 @@ import unittest
from mopidy.backends.local import actor
from mopidy.core import PlaybackState
from mopidy.models import Track
from mopidy.utils.path import path_to_uri
from tests import path_to_data_dir
from tests.backends.base.playback import PlaybackControllerTest
@ -24,25 +23,25 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase):
tracks = [
Track(uri=generate_song(i), length=4464) for i in range(1, 4)]
def add_track(self, path):
uri = path_to_uri(path_to_data_dir(path))
def add_track(self, uri):
track = Track(uri=uri, length=4464)
self.tracklist.add([track])
def test_uri_scheme(self):
self.assertIn('file', self.core.uri_schemes)
self.assertNotIn('file', self.core.uri_schemes)
self.assertIn('local', self.core.uri_schemes)
def test_play_mp3(self):
self.add_track('blank.mp3')
self.add_track('local:track:blank.mp3')
self.playback.play()
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
def test_play_ogg(self):
self.add_track('blank.ogg')
self.add_track('local:track:blank.ogg')
self.playback.play()
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
def test_play_flac(self):
self.add_track('blank.flac')
self.add_track('local:track:blank.flac')
self.playback.play()
self.assertEqual(self.playback.state, PlaybackState.PLAYING)

View File

@ -7,7 +7,7 @@ import unittest
from mopidy.backends.local import actor
from mopidy.models import Track
from mopidy.utils.path import path_to_uri
from mopidy.utils.path import path_to_uri, uri_to_path
from tests import path_to_data_dir
from tests.backends.base.playlists import (
@ -89,21 +89,20 @@ class LocalPlaylistsControllerTest(
def test_playlist_contents_is_written_to_disk(self):
track = Track(uri=generate_song(1))
track_path = track.uri[len('file://'):]
playlist = self.core.playlists.create('test')
playlist_path = playlist.uri[len('file://'):]
playlist_path = os.path.join(self.playlists_dir, 'test.m3u')
playlist = playlist.copy(tracks=[track])
playlist = self.core.playlists.save(playlist)
with open(playlist_path) as playlist_file:
contents = playlist_file.read()
self.assertEqual(track_path, contents.strip())
self.assertEqual(track.uri, contents.strip())
def test_playlists_are_loaded_at_startup(self):
playlist_path = os.path.join(self.playlists_dir, 'test.m3u')
track = Track(uri=path_to_uri(path_to_data_dir('uri2')))
track = Track(uri='local:track:path2')
playlist = self.core.playlists.create('test')
playlist = playlist.copy(tracks=[track])
playlist = self.core.playlists.save(playlist)
@ -112,8 +111,7 @@ class LocalPlaylistsControllerTest(
self.assert_(backend.playlists.playlists)
self.assertEqual(
path_to_uri(playlist_path),
backend.playlists.playlists[0].uri)
'local:playlist:test', backend.playlists.playlists[0].uri)
self.assertEqual(
playlist.name, backend.playlists.playlists[0].name)
self.assertEqual(

View File

@ -98,7 +98,7 @@ expected_tracks = []
def generate_track(path, ident):
uri = path_to_uri(path_to_data_dir(path))
uri = 'local:track:%s' % path
track = Track(
uri=uri, name='trackname', artists=expected_artists,
album=expected_albums[0], track_no=1, date='2006', length=4000,
@ -126,11 +126,10 @@ class MPDTagCacheToTracksTest(unittest.TestCase):
def test_simple_cache(self):
tracks = parse_mpd_tag_cache(
path_to_data_dir('simple_tag_cache'), path_to_data_dir(''))
uri = path_to_uri(path_to_data_dir('song1.mp3'))
track = Track(
uri=uri, name='trackname', artists=expected_artists, track_no=1,
album=expected_albums[0], date='2006', length=4000,
last_modified=1272319626)
uri='local:track:song1.mp3', name='trackname',
artists=expected_artists, track_no=1, album=expected_albums[0],
date='2006', length=4000, last_modified=1272319626)
self.assertEqual(set([track]), tracks)
def test_advanced_cache(self):
@ -142,12 +141,11 @@ class MPDTagCacheToTracksTest(unittest.TestCase):
tracks = parse_mpd_tag_cache(
path_to_data_dir('utf8_tag_cache'), path_to_data_dir(''))
uri = path_to_uri(path_to_data_dir('song1.mp3'))
artists = [Artist(name='æøå')]
album = Album(name='æøå', artists=artists)
track = Track(
uri=uri, name='æøå', artists=artists, album=album, length=4000,
last_modified=1272319626)
uri='local:track:song1.mp3', name='æøå', artists=artists,
album=album, length=4000, last_modified=1272319626)
self.assertEqual(track, list(tracks)[0])
@ -159,8 +157,8 @@ class MPDTagCacheToTracksTest(unittest.TestCase):
def test_cache_with_blank_track_info(self):
tracks = parse_mpd_tag_cache(
path_to_data_dir('blank_tag_cache'), path_to_data_dir(''))
uri = path_to_uri(path_to_data_dir('song1.mp3'))
expected = Track(uri=uri, length=4000, last_modified=1272319626)
expected = Track(
uri='local:track:song1.mp3', length=4000, last_modified=1272319626)
self.assertEqual(set([expected]), tracks)
def test_musicbrainz_tagcache(self):
@ -183,10 +181,10 @@ class MPDTagCacheToTracksTest(unittest.TestCase):
def test_albumartist_tag_cache(self):
tracks = parse_mpd_tag_cache(
path_to_data_dir('albumartist_tag_cache'), path_to_data_dir(''))
uri = path_to_uri(path_to_data_dir('song1.mp3'))
artist = Artist(name='albumartistname')
album = expected_albums[0].copy(artists=[artist])
track = Track(
uri=uri, name='trackname', artists=expected_artists, track_no=1,
album=album, date='2006', length=4000, last_modified=1272319626)
uri='local:track:song1.mp3', name='trackname',
artists=expected_artists, track_no=1, album=album, date='2006',
length=4000, last_modified=1272319626)
self.assertEqual(track, list(tracks)[0])

View File

@ -5,7 +5,6 @@ from __future__ import unicode_literals
import logging
import mock
import socket
import sys
import unittest
from mopidy.config import types
@ -99,6 +98,11 @@ class StringTest(unittest.TestCase):
self.assertIsInstance(result, bytes)
self.assertEqual(b'', result)
def test_deserialize_enforces_choices_optional(self):
value = types.String(optional=True, choices=['foo', 'bar', 'baz'])
self.assertEqual(None, value.deserialize(b''))
self.assertRaises(ValueError, value.deserialize, b'foobar')
class SecretTest(unittest.TestCase):
def test_deserialize_passes_through(self):
@ -164,6 +168,10 @@ class IntegerTest(unittest.TestCase):
self.assertEqual(5, value.deserialize('5'))
self.assertRaises(ValueError, value.deserialize, '15')
def test_deserialize_respects_optional(self):
value = types.Integer(optional=True)
self.assertEqual(None, value.deserialize(''))
class BooleanTest(unittest.TestCase):
def test_deserialize_conversion_success(self):
@ -367,7 +375,4 @@ class PathTest(unittest.TestCase):
def test_serialize_unicode_string(self):
value = types.Path()
expected = 'æøå'.encode(sys.getfilesystemencoding())
result = value.serialize('æøå')
self.assertEqual(expected, result)
self.assertIsInstance(result, bytes)
self.assertRaises(ValueError, value.serialize, 'æøå')

View File

@ -3,22 +3,22 @@ mpd_version: 0.14.2
fs_charset: UTF-8
info_end
songList begin
key: uri1
file: /uri1
key: key1
file: /path1
Artist: artist1
Title: track1
Album: album1
Date: 2001-02-03
Time: 4
key: uri2
file: /uri2
key: key1
file: /path2
Artist: artist2
Title: track2
Album: album2
Date: 2002
Time: 4
key: uri3
file: /uri3
key: key3
file: /path3
Artist: artist3
Title: track3
Album: album3

Binary file not shown.

Binary file not shown.

View File

@ -7,7 +7,19 @@ from tests.frontends.mpd import protocol
class MusicDatabaseHandlerTest(protocol.BaseTestCase):
def test_count(self):
self.sendRequest('count "tag" "needle"')
self.sendRequest('count "artist" "needle"')
self.assertInResponse('songs: 0')
self.assertInResponse('playtime: 0')
self.assertInResponse('OK')
def test_count_without_quotes(self):
self.sendRequest('count artist "needle"')
self.assertInResponse('songs: 0')
self.assertInResponse('playtime: 0')
self.assertInResponse('OK')
def test_count_with_multiple_pairs(self):
self.sendRequest('count "artist" "foo" "album" "bar"')
self.assertInResponse('songs: 0')
self.assertInResponse('playtime: 0')
self.assertInResponse('OK')

View File

@ -107,6 +107,30 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertNotInResponse('playlist: ')
self.assertInResponse('OK')
def test_listplaylists_replaces_newline_with_space(self):
self.backend.playlists.playlists = [
Playlist(name='a\n', uri='dummy:')]
self.sendRequest('listplaylists')
self.assertInResponse('playlist: a ')
self.assertNotInResponse('playlist: a\n')
self.assertInResponse('OK')
def test_listplaylists_replaces_carriage_return_with_space(self):
self.backend.playlists.playlists = [
Playlist(name='a\r', uri='dummy:')]
self.sendRequest('listplaylists')
self.assertInResponse('playlist: a ')
self.assertNotInResponse('playlist: a\r')
self.assertInResponse('OK')
def test_listplaylists_replaces_forward_slash_with_space(self):
self.backend.playlists.playlists = [
Playlist(name='a/', uri='dummy:')]
self.sendRequest('listplaylists')
self.assertInResponse('playlist: a ')
self.assertNotInResponse('playlist: a/')
self.assertInResponse('OK')
def test_load_appends_to_tracklist(self):
self.core.tracklist.add([Track(uri='a'), Track(uri='b')])
self.assertEqual(len(self.core.tracklist.tracks.get()), 2)

View File

@ -26,6 +26,8 @@ class TranslatorTest(unittest.TestCase):
'album-artist': 'albumartistname',
'title': 'trackname',
'track-count': 2,
'album-disc-number': 2,
'album-disc-count': 3,
'date': FakeGstDate(2006, 1, 1,),
'container-format': 'ID3 tag',
'duration': 4531,
@ -39,6 +41,7 @@ class TranslatorTest(unittest.TestCase):
self.album = {
'name': 'albumname',
'num_tracks': 2,
'num_discs': 3,
'musicbrainz_id': 'mbalbumid',
}
@ -57,6 +60,7 @@ class TranslatorTest(unittest.TestCase):
'name': 'trackname',
'date': '2006-01-01',
'track_no': 1,
'disc_no': 2,
'length': 4531,
'musicbrainz_id': 'mbtrackid',
'last_modified': 1234,
@ -206,6 +210,14 @@ class ScannerTest(unittest.TestCase):
self.scan('scanner/image')
self.assert_(self.errors)
def test_log_file_is_ignored(self):
self.scan('scanner/example.log')
self.assert_(self.errors)
def test_empty_wav_file_is_ignored(self):
self.scan('scanner/empty.wav')
self.assert_(self.errors)
@unittest.SkipTest
def test_song_without_time_is_handeled(self):
pass

View File

@ -4,7 +4,6 @@ from __future__ import unicode_literals
import os
import shutil
import sys
import tempfile
import unittest
@ -117,86 +116,42 @@ class GetOrCreateFileTest(unittest.TestCase):
class PathToFileURITest(unittest.TestCase):
def test_simple_path(self):
if sys.platform == 'win32':
result = path.path_to_uri('C:/WINDOWS/clock.avi')
self.assertEqual(result, 'file:///C://WINDOWS/clock.avi')
else:
result = path.path_to_uri('/etc/fstab')
self.assertEqual(result, 'file:///etc/fstab')
def test_dir_and_path(self):
if sys.platform == 'win32':
result = path.path_to_uri('C:/WINDOWS/', 'clock.avi')
self.assertEqual(result, 'file:///C://WINDOWS/clock.avi')
else:
result = path.path_to_uri('/etc', 'fstab')
self.assertEqual(result, 'file:///etc/fstab')
result = path.path_to_uri('/etc/fstab')
self.assertEqual(result, 'file:///etc/fstab')
def test_space_in_path(self):
if sys.platform == 'win32':
result = path.path_to_uri('C:/test this')
self.assertEqual(result, 'file:///C://test%20this')
else:
result = path.path_to_uri('/tmp/test this')
self.assertEqual(result, 'file:///tmp/test%20this')
result = path.path_to_uri('/tmp/test this')
self.assertEqual(result, 'file:///tmp/test%20this')
def test_unicode_in_path(self):
if sys.platform == 'win32':
result = path.path_to_uri('C:/æøå')
self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5')
else:
result = path.path_to_uri('/tmp/æøå')
self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5')
result = path.path_to_uri('/tmp/æøå')
self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5')
def test_utf8_in_path(self):
if sys.platform == 'win32':
result = path.path_to_uri('C:/æøå'.encode('utf-8'))
self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5')
else:
result = path.path_to_uri('/tmp/æøå'.encode('utf-8'))
self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5')
result = path.path_to_uri('/tmp/æøå'.encode('utf-8'))
self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5')
def test_latin1_in_path(self):
if sys.platform == 'win32':
result = path.path_to_uri('C:/æøå'.encode('latin-1'))
self.assertEqual(result, 'file:///C://%E6%F8%E5')
else:
result = path.path_to_uri('/tmp/æøå'.encode('latin-1'))
self.assertEqual(result, 'file:///tmp/%E6%F8%E5')
result = path.path_to_uri('/tmp/æøå'.encode('latin-1'))
self.assertEqual(result, 'file:///tmp/%E6%F8%E5')
class UriToPathTest(unittest.TestCase):
def test_simple_uri(self):
if sys.platform == 'win32':
result = path.uri_to_path('file:///C://WINDOWS/clock.avi')
self.assertEqual(result, 'C:/WINDOWS/clock.avi'.encode('utf-8'))
else:
result = path.uri_to_path('file:///etc/fstab')
self.assertEqual(result, '/etc/fstab'.encode('utf-8'))
result = path.uri_to_path('file:///etc/fstab')
self.assertEqual(result, '/etc/fstab'.encode('utf-8'))
def test_space_in_uri(self):
if sys.platform == 'win32':
result = path.uri_to_path('file:///C://test%20this')
self.assertEqual(result, 'C:/test this'.encode('utf-8'))
else:
result = path.uri_to_path('file:///tmp/test%20this')
self.assertEqual(result, '/tmp/test this'.encode('utf-8'))
result = path.uri_to_path('file:///tmp/test%20this')
self.assertEqual(result, '/tmp/test this'.encode('utf-8'))
def test_unicode_in_uri(self):
if sys.platform == 'win32':
result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5')
self.assertEqual(result, 'C:/æøå'.encode('utf-8'))
else:
result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5')
self.assertEqual(result, '/tmp/æøå'.encode('utf-8'))
result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5')
self.assertEqual(result, '/tmp/æøå'.encode('utf-8'))
def test_latin1_in_uri(self):
if sys.platform == 'win32':
result = path.uri_to_path('file:///C://%E6%F8%E5')
self.assertEqual(result, 'C:/æøå'.encode('latin-1'))
else:
result = path.uri_to_path('file:///tmp/%E6%F8%E5')
self.assertEqual(result, '/tmp/æøå'.encode('latin-1'))
result = path.uri_to_path('file:///tmp/%E6%F8%E5')
self.assertEqual(result, '/tmp/æøå'.encode('latin-1'))
class SplitPathTest(unittest.TestCase):

View File

@ -37,5 +37,6 @@ class VersionTest(unittest.TestCase):
self.assertLess(SV('0.11.1'), SV('0.12.0'))
self.assertLess(SV('0.12.0'), SV('0.13.0'))
self.assertLess(SV('0.13.0'), SV('0.14.0'))
self.assertLess(SV('0.14.0'), SV(__version__))
self.assertLess(SV(__version__), SV('0.14.2'))
self.assertLess(SV('0.14.0'), SV('0.14.1'))
self.assertLess(SV('0.14.1'), SV(__version__))
self.assertLess(SV(__version__), SV('0.14.3'))