Release v0.17.0

This commit is contained in:
Stein Magnus Jodal 2013-11-23 20:23:06 +01:00
commit 2b7bc870cf
75 changed files with 2759 additions and 923 deletions

View File

@ -28,3 +28,4 @@
- Pavol Babincak <scroolik@gmail.com> - Pavol Babincak <scroolik@gmail.com>
- Javier Domingo <javierdo1@gmail.com> - Javier Domingo <javierdo1@gmail.com>
- Lasse Bigum <lasse@bigum.org> - Lasse Bigum <lasse@bigum.org>
- David Eisner <david.eisner@oriel.oxon.org>

View File

@ -28,3 +28,10 @@ Audio listener
.. autoclass:: mopidy.audio.AudioListener .. autoclass:: mopidy.audio.AudioListener
:members: :members:
Audio scanner
=============
.. autoclass:: mopidy.audio.scan.Scanner
:members:

9
docs/api/commands.rst Normal file
View File

@ -0,0 +1,9 @@
.. _commands-api:
************
Commands API
************
.. automodule:: mopidy.commands
:synopsis: Commands API for Mopidy CLI.
:members:

View File

@ -13,6 +13,7 @@ API reference
core core
audio audio
frontends frontends
commands
ext ext
config config
http http

View File

@ -5,6 +5,126 @@ Changelog
This changelog is used to track all major changes to Mopidy. This changelog is used to track all major changes to Mopidy.
v0.17.0 (2013-11-23)
====================
The focus of 0.17 has been on introducing subcommands to the ``mopidy``
command, making it possible for extensions to add subcommands of their own, and
to improve the default config file when starting Mopidy the first time. In
addition, we've grown support for Zeroconf publishing of the MPD and HTTP
servers, and gotten a much faster scanner. The scanner now also scans some
additional tags like composers and performers.
Since the release of 0.16, we've closed or merged 22 issues and pull requests
through about 200 commits by :ref:`five people <authors>`, including one new
contributor.
**Commands**
- Switched to subcommands for the ``mopidy`` command , this implies the
following changes: (Fixes: :issue:`437`)
===================== =================
Old command New command
===================== =================
mopidy --show-deps mopidy deps
mopidy --show-config mopidy config
mopidy-scan mopidy local scan
===================== =================
- Added hooks for extensions to create their own custom subcommands and
converted ``mopidy-scan`` as a first user of the new API. (Fixes:
:issue:`436`)
**Configuration**
- When ``mopidy`` is started for the first time we create an empty
:file:`{$XDG_CONFIG_DIR}/mopidy/mopidy.conf` file. We now populate this file
with the default config for all installed extensions so it'll be easier to
set up Mopidy without looking through all the documentation for relevant
config values. (Fixes: :issue:`467`)
**Core API**
- The :class:`~mopidy.models.Track` model has grown fields for ``composers``,
``performers``, ``genre``, and ``comment``.
- The search field ``track`` has been renamed to ``track_name`` to avoid
confusion with ``track_no``. (Fixes: :issue:`535`)
- The signature of the tracklist's
:meth:`~mopidy.core.TracklistController.filter` and
:meth:`~mopidy.core.TracklistController.remove` methods have changed.
Previously, they expected e.g. ``tracklist.filter(tlid=17)``. Now, the value
must always be a list, e.g. ``tracklist.filter(tlid=[17])``. This change
allows you to get or remove multiple tracks with a single call, e.g.
``tracklist.remove(tlid=[1, 2, 7])``. This is especially useful for web
clients, as requests can be batched. This also brings the interface closer to
the library's :meth:`~mopidy.core.LibraryController.find_exact` and
:meth:`~mopidy.core.LibraryController.search` methods.
**Audio**
- Change default volume mixer from ``autoaudiomixer`` to ``software``.
GStreamer 1.x does not support volume control, so we're changing to use
software mixing by default, as that may be the only thing we'll support in
the future when we upgrade to GStreamer 1.x.
**Local backend**
- Library scanning has been switched back from GStreamer's discoverer to our
custom implementation due to various issues with GStreamer 0.10's built in
scanner. This also fixes the scanner slowdown. (Fixes: :issue:`565`)
- When scanning, we no longer default the album artist to be the same as the
track artist. Album artist is now only populated if the scanned file got an
explicit album artist set.
- The scanner will now extract multiple artists from files with multiple artist
tags.
- The scanner will now extract composers and performers, as well as genre,
bitrate, and comments. (Fixes: :issue:`577`)
- Fix scanner so that time of last modification is respected when deciding
which files can be skipped when scanning the music collection for changes.
- The scanner now ignores the capitalization of file extensions in
:confval:`local/excluded_file_extensions`, so you no longer need to list both
``.jpg`` and ``.JPG`` to ignore JPEG files when scanning. (Fixes:
:issue:`525`)
- The scanner now by default ignores ``*.nfo`` and ``*.html`` files too.
**MPD frontend**
- The MPD service is now published as a Zeroconf service if avahi-daemon is
running on the system. Some MPD clients will use this to present Mopidy as an
available server on the local network without needing any configuration. See
the :confval:`mpd/zeroconf` config value to change the service name or
disable the service. (Fixes: :issue:`39`)
- Add support for ``composer``, ``performer``, ``comment``, ``genre``, and
``performer``. These tags can be used with ``list ...``, ``search ...``, and
``find ...`` and their variants, and are supported in the ``any`` tag also
- The ``bitrate`` field in the ``status`` response is now always an integer.
This follows the behavior of the original MPD server. (Fixes: :issue:`577`)
**HTTP frontend**
- The HTTP service is now published as a Zeroconf service if avahi-daemon is
running on the system. Some browsers will present HTTP Zeroconf services on
the local network as "local sites" bookmarks. See the
:confval:`http/zeroconf` config value to change the service name or disable
the service. (Fixes: :issue:`39`)
**DBUS/MPRIS**
- The ``mopidy`` process now registers it's GObject event loop as the default
eventloop for dbus-python. (Fixes: :mpris:`2`)
v0.16.1 (2013-11-02) v0.16.1 (2013-11-02)
==================== ====================
@ -25,7 +145,7 @@ in Debian.
**MPD frontend** **MPD frontend**
- Add support for ``list "albumartist" ...`` which was missed when ``find`` and - Add support for ``list "albumartist" ...`` which was missed when ``find`` and
``search`` learned to handle ``albumartist`` in 0.16.0. ``search`` learned to handle ``albumartist`` in 0.16.0. (Fixes: :issue:`553`)
v0.16.0 (2013-10-27) v0.16.0 (2013-10-27)

View File

@ -19,8 +19,8 @@ combines results from all music sources, and you can mix tracks from all
sources in your play queue. Your playlists from Spotify or SoundCloud are also sources in your play queue. Your playlists from Spotify or SoundCloud are also
available for use. available for use.
The ``mopidy-convert-config`` command is used to convert ``settings.py`` The ``mopidy-convert-config`` command is used to convert :file:`settings.py`
configuration files used by ``mopidy`` < 0.14 to the ``mopidy.conf`` config configuration files used by ``mopidy`` < 0.14 to the :file:`mopidy.conf` config
file used by ``mopidy`` >= 0.14. file used by ``mopidy`` >= 0.14.
@ -30,16 +30,16 @@ Options
.. program:: mopidy-convert-config .. program:: mopidy-convert-config
This program does not take any options. It looks for the pre-0.14 settings file This program does not take any options. It looks for the pre-0.14 settings file
at ``$XDG_CONFIG_DIR/mopidy/settings.py``, and if it exists it converts it and at :file:`{$XDG_CONFIG_DIR}/mopidy/settings.py`, and if it exists it converts
ouputs a Mopidy 0.14 compatible ini-format configuration. If you don't already it and ouputs a Mopidy 0.14 compatible ini-format configuration. If you don't
have a config file at ``$XDG_CONFIG_DIR/mopidy/mopidy.conf``, you're asked if already have a config file at :file:`{$XDG_CONFIG_DIR}/mopidy/mopidy.conf``,
you want to save the converted config to that file. you're asked if you want to save the converted config to that file.
Example Example
======= =======
Given the following contents in ``~/.config/mopidy/settings.py``: Given the following contents in :file:`~/.config/mopidy/settings.py`:
:: ::
@ -49,7 +49,7 @@ Given the following contents in ``~/.config/mopidy/settings.py``:
SPOTIFY_USERNAME = u'alice' SPOTIFY_USERNAME = u'alice'
Running ``mopidy-convert-config`` will convert the config and create a new Running ``mopidy-convert-config`` will convert the config and create a new
``mopidy.conf`` config file: :file:`mopidy.conf` config file:
.. code-block:: none .. code-block:: none
@ -70,7 +70,7 @@ Running ``mopidy-convert-config`` will convert the config and create a new
Write new config to /home/alice/.config/mopidy/mopidy.conf? [yN] y Write new config to /home/alice/.config/mopidy/mopidy.conf? [yN] y
Done. Done.
Contents of ``~/.config/mopidy/mopidy.conf`` after the conversion: Contents of :file:`~/.config/mopidy/mopidy.conf` after the conversion:
.. code-block:: ini .. code-block:: ini

View File

@ -1,59 +0,0 @@
.. _mopidy-scan-cmd:
*******************
mopidy-scan command
*******************
Synopsis
========
mopidy-scan
[-h] [--version] [-q] [-v]
Description
===========
Mopidy is a music server which can play music both from multiple sources, like
your local hard drive, radio streams, and from Spotify and SoundCloud. Searches
combines results from all music sources, and you can mix tracks from all
sources in your play queue. Your playlists from Spotify or SoundCloud are also
available for use.
The ``mopidy-scan`` command is used to index a music library to make it
available for playback with ``mopidy``.
Options
=======
.. program:: mopidy-scan
.. cmdoption:: --version
Show Mopidy's version number and exit.
.. cmdoption:: -h, --help
Show help message and exit.
.. cmdoption:: -q, --quiet
Show less output: warning level and higher.
.. cmdoption:: -v, --verbose
Show more output: debug level and higher.
See also
========
:ref:`mopidy(1) <mopidy-cmd>`
Reporting bugs
==============
Report bugs to Mopidy's issue tracker at
<https://github.com/mopidy/mopidy/issues>

View File

@ -8,8 +8,8 @@ Synopsis
======== ========
mopidy mopidy
[-h] [--version] [-q] [-v] [--save-debug-log] [--show-config] [-h] [--version] [-q] [-v] [--save-debug-log] [--config CONFIG_FILES]
[--show-deps] [--config CONFIG_FILES] [-o CONFIG_OVERRIDES] [-o CONFIG_OVERRIDES] [COMMAND] ...
Description Description
@ -29,7 +29,7 @@ Options
.. program:: mopidy .. program:: mopidy
.. cmdoption:: -h, --help .. cmdoption:: --help, -h
Show help message and exit. Show help message and exit.
@ -37,11 +37,11 @@ Options
Show Mopidy's version number and exit. Show Mopidy's version number and exit.
.. cmdoption:: -q, --quiet .. cmdoption:: --quiet, -q
Show less output: warning level and higher. Show less output: warning level and higher.
.. cmdoption:: -v, --verbose .. cmdoption:: --verbose, -v
Show more output: debug level and higher. Show more output: debug level and higher.
@ -50,35 +50,51 @@ Options
Save debug log to the file specified in the :confval:`logging/debug_file` Save debug log to the file specified in the :confval:`logging/debug_file`
config value, typically ``./mopidy.log``. config value, typically ``./mopidy.log``.
.. cmdoption:: --show-config
Show the current effective config. All configuration sources are merged
together to show the effective document. Secret values like passwords are
masked out. Config for disabled extensions are not included.
.. cmdoption:: --show-deps
Show dependencies, their versions and installation location.
.. cmdoption:: --config <file> .. cmdoption:: --config <file>
Specify config file to use. To use multiple config files, separate them Specify config file to use. To use multiple config files, separate them
with colon. The later files override the earlier ones if there's a with a colon. The later files override the earlier ones if there's a
conflict. conflict.
.. cmdoption:: -o <option>, --option <option> .. cmdoption:: --option <option>, -o <option>
Specify additional config values in the ``section/key=value`` format. Can Specify additional config values in the ``section/key=value`` format. Can
be provided multiple times. be provided multiple times.
Built in commands
=================
.. cmdoption:: config
Show the current effective config. All configuration sources are merged
together to show the effective document. Secret values like passwords are
masked out. Config for disabled extensions are not included.
.. cmdoption:: deps
Show dependencies, their versions and installation location.
Extension commands
==================
Additionally, extensions can provide extra commands. Run `mopidy --help`
for a list of what is available on your system and command-specific help.
Commands for disabled extensions will be listed, but can not be run.
.. cmdoption:: local scan
Scan local media files present in your library.
Files Files
===== =====
/etc/mopidy/mopidy.conf :file:`/etc/mopidy/mopidy.conf`
System wide Mopidy configuration file. System wide Mopidy configuration file.
~/.config/mopidy/mopidy.conf :file:`~/.config/mopidy/mopidy.conf`
Your personal Mopidy configuration file. Overrides any configs from the Your personal Mopidy configuration file. Overrides any configs from the
system wide configuration file. system wide configuration file.
@ -105,17 +121,16 @@ configs::
mopidy -o mpd/enabled=false -o spotify/bitrate=320 mopidy -o mpd/enabled=false -o spotify/bitrate=320
The :option:`--show-config` output shows the effect of the :option:`--option` The ``mopidy config`` output shows the effect of the :option:`--option` flags::
flags::
mopidy -o mpd/enabled=false -o spotify/bitrate=320 --show-config mopidy -o mpd/enabled=false -o spotify/bitrate=320 config
See also See also
======== ========
:ref:`mopidy-scan(1) <mopidy-scan-cmd>`, :ref:`mopidy-convert-config(1) :ref:`mopidy-convert-config(1) <mopidy-convert-config>`
<mopidy-convert-config>`
Reporting bugs Reporting bugs
============== ==============

View File

@ -8,11 +8,6 @@ import os
import sys import sys
# -- Read The Docs configuration ----------------------------------------------
RTD_NEW_THEME = True
# -- Workarounds to have autodoc generate API docs ---------------------------- # -- Workarounds to have autodoc generate API docs ----------------------------
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
@ -146,13 +141,6 @@ man_pages = [
'', '',
'1' '1'
), ),
(
'commands/mopidy-scan',
'mopidy-scan',
'index music for playback with mopidy',
'',
'1'
),
( (
'commands/mopidy-convert-config', 'commands/mopidy-convert-config',
'mopidy-convert-config', 'mopidy-convert-config',
@ -165,4 +153,8 @@ man_pages = [
# -- Options for extlink extension -------------------------------------------- # -- Options for extlink extension --------------------------------------------
extlinks = {'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#')} extlinks = {
'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#'),
'mpris': (
'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'),
}

View File

@ -3,8 +3,8 @@ Configuration
************* *************
Mopidy has a lot of config values you can tweak, but you only need to change a Mopidy has a lot of config values you can tweak, but you only need to change a
few to get up and running. A complete ``~/.config/mopidy/mopidy.conf`` may be few to get up and running. A complete :file:`~/.config/mopidy/mopidy.conf` may
as simple as this: be as simple as this:
.. code-block:: ini .. code-block:: ini
@ -15,17 +15,18 @@ as simple as this:
username = alice username = alice
password = mysecret password = mysecret
Mopidy primarily reads config from the file ``~/.config/mopidy/mopidy.conf``, Mopidy primarily reads config from the file
where ``~`` means your *home directory*. If your username is ``alice`` and you :file:`~/.config/mopidy/mopidy.conf`, where ``~`` means your *home directory*.
are running Linux, the config file should probably be at If your username is ``alice`` and you are running Linux, the config file should
``/home/alice/.config/mopidy/mopidy.conf``. You can either create the probably be at :file:`/home/alice/.config/mopidy/mopidy.conf`. You can either
configuration file yourself, or run the ``mopidy`` command, and it will create create the configuration file yourself, or run the ``mopidy`` command, and it
an empty config file for you and print what config values must be set to will create an empty config file for you and print what config values must be
successfully start Mopidy. set to successfully start Mopidy.
When you have created the configuration file, open it in a text editor, and add When you have created the configuration file, open it in a text editor, and add
the config values you want to change. If you want to keep the default for a the config values you want to change. If you want to keep the default for a
config value, you **should not** add it to ``~/.config/mopidy/mopidy.conf``. config value, you **should not** add it to
:file:`~/.config/mopidy/mopidy.conf`.
To see what's the effective configuration for your Mopidy installation, you can To see what's the effective configuration for your Mopidy installation, you can
run :option:`mopidy --show-config`. It will print your full effective config run :option:`mopidy --show-config`. It will print your full effective config
@ -60,17 +61,23 @@ Core configuration values
Audio mixer to use. Audio mixer to use.
Expects a GStreamer mixer to use, typical values are: ``autoaudiomixer``, Expects a GStreamer mixer to use, typical values are: ``software``,
``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``. ``autoaudiomixer``, ``alsamixer``, ``pulsemixer``, ``ossmixer``, and
``oss4mixer``.
The default is ``autoaudiomixer``, which attempts to select a sane mixer The default is ``software``, which does volume control inside Mopidy before
for you automatically. When Mopidy is started, it will log what mixer the audio is sent to the audio output. This mixer does not affect the
``autoaudiomixer`` selected, for example:: volume of any other audio playback on the system. It is the only mixer that
will affect the audio volume if you're streaming the audio from Mopidy
through Shoutcast.
If you want to use a hardware mixer, try ``autoaudiomixer``. It attempts to
select a sane hardware mixer for you automatically. When Mopidy is started,
it will log what mixer ``autoaudiomixer`` selected, for example::
INFO Audio mixer set to "alsamixer" using track "Master" INFO Audio mixer set to "alsamixer" using track "Master"
Setting the config value to blank turns off volume control. ``software`` Setting the config value to blank turns off volume control.
can be used to force software mixing in the application.
.. confval:: audio/mixer_track .. confval:: audio/mixer_track

View File

@ -59,6 +59,13 @@ Configuration values
Change this to have Mopidy serve e.g. files for your JavaScript client. Change this to have Mopidy serve e.g. files for your JavaScript client.
"/mopidy" will continue to work as usual even if you change this setting. "/mopidy" will continue to work as usual even if you change this setting.
.. confval:: http/zeroconf
Name of the HTTP service when published through Zeroconf. The variables
``$hostname`` and ``$port`` can be used in the name.
Set to an empty string to disable Zeroconf for HTTP.
Usage Usage
===== =====

View File

@ -6,7 +6,7 @@ Mopidy-Local
Extension for playing music from a local music archive. Extension for playing music from a local music archive.
This backend handles URIs starting with ``file:``. This backend handles URIs starting with ``local:``.
Known issues Known issues
@ -71,7 +71,7 @@ music...
Generating a tag cache Generating a tag cache
---------------------- ----------------------
The program :command:`mopidy-scan` will scan the path set in the The command :command:`mopidy local scan` will scan the path set in the
:confval:`local/media_dir` config value for any media files and build a MPD :confval:`local/media_dir` config value for any media files and build a MPD
compatible ``tag_cache``. compatible ``tag_cache``.
@ -80,16 +80,11 @@ To make a ``tag_cache`` of your local music available for Mopidy:
#. Ensure that the :confval:`local/media_dir` config value points to where your #. Ensure that the :confval:`local/media_dir` config value points to where your
music is located. Check the current setting by running:: music is located. Check the current setting by running::
mopidy --show-config mopidy config
#. Scan your media library. The command outputs the ``tag_cache`` to #. Scan your media library. The command writes the ``tag_cache`` to
standard output, which means that you will need to redirect the output to a the :confval:`local/tag_cache_file`::
file yourself::
mopidy-scan > tag_cache mopidy local scan
#. Move the ``tag_cache`` file to the location
set in the :confval:`local/tag_cache_file` config value, or change the
config value to point to where your ``tag_cache`` file is.
#. Start Mopidy, find the music library in a client, and play some local music! #. Start Mopidy, find the music library in a client, and play some local music!

View File

@ -33,9 +33,7 @@ Items on this list will probably not be supported in the near future.
- Stickers are not supported - Stickers are not supported
- Crossfade is not supported - Crossfade is not supported
- Replay gain is not supported - Replay gain is not supported
- ``count`` does not provide any statistics
- ``stats`` does not provide any statistics - ``stats`` does not provide any statistics
- ``list`` does not support listing tracks by genre
- ``decoders`` does not provide information about available decoders - ``decoders`` does not provide information about available decoders
The following items are currently not supported, but should be added in the The following items are currently not supported, but should be added in the
@ -98,6 +96,13 @@ Configuration values
Number of seconds an MPD client can stay inactive before the connection is Number of seconds an MPD client can stay inactive before the connection is
closed by the server. closed by the server.
.. confval:: mpd/zeroconf
Name of the MPD service when published through Zeroconf. The variables
``$hostname`` and ``$port`` can be used in the name.
Set to an empty string to disable Zeroconf for MPD.
Usage Usage
===== =====

View File

@ -305,6 +305,10 @@ This is ``mopidy_soundspot/__init__.py``::
from .backend import SoundspotBackend from .backend import SoundspotBackend
return [SoundspotBackend] return [SoundspotBackend]
def get_command(self):
from .commands import SoundspotCommand
return SoundspotCommand()
def register_gstreamer_elements(self): def register_gstreamer_elements(self):
from .mixer import SoundspotMixer from .mixer import SoundspotMixer
gobject.type_register(SoundspotMixer) gobject.type_register(SoundspotMixer)
@ -353,7 +357,8 @@ Example backend
If you want to extend Mopidy to support new music and playlist sources, you If you want to extend Mopidy to support new music and playlist sources, you
want to implement a backend. A backend does not have access to Mopidy's core want to implement a backend. A backend does not have access to Mopidy's core
API at all and got a bunch of interfaces to implement. API at all, but it does have a bunch of interfaces it can implement to extend
Mopidy.
The skeleton of a backend would look like this. See :ref:`backend-api` for more The skeleton of a backend would look like this. See :ref:`backend-api` for more
details. details.
@ -373,6 +378,34 @@ details.
# Your backend implementation # Your backend implementation
Example command
===============
If you want to extend the Mopidy with a new helper not run from the server,
such as scanning for media, adding a command is the way to go. Your top level
command name will always match your extension name, but you are free to add
sub-commands with names of your choosing.
The skeleton of a commands would look like this. See :ref:`command-api` for more
details.
::
from mopidy import commands
class SoundspotCommand(commands.Command):
help = 'Some text that will show up in --help'
def __init__(self):
super(SoundspotCommand, self).__init__()
self.add_argument('--foo')
def run(self, args, config, extensions):
# Your backend implementation
return 0
Example GStreamer element Example GStreamer element
========================= =========================

View File

@ -7,7 +7,7 @@ To start Mopidy, simply open a terminal and run::
mopidy mopidy
For a complete reference to the Mopidy commands and their command line options, For a complete reference to the Mopidy commands and their command line options,
see :ref:`mopidy-cmd` and :ref:`mopidy-scan-cmd`. see :ref:`mopidy-cmd`.
When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to
accept connections by any MPD client. Check out our non-exhaustive accept connections by any MPD client. Check out our non-exhaustive

View File

@ -28,7 +28,7 @@ accepted, but large logs should still be shared through a pastebin.
Effective configuration Effective configuration
======================= =======================
The command :option:`mopidy --show-config` will print your full effective The command ``mopidy config`` will print your full effective
configuration the way Mopidy sees it after all defaults and all config files configuration the way Mopidy sees it after all defaults and all config files
have been merged into a single config document. Any secret values like have been merged into a single config document. Any secret values like
passwords are masked out, so the output of the command should be safe to share passwords are masked out, so the output of the command should be safe to share
@ -38,7 +38,7 @@ with others for debugging.
Installed dependencies Installed dependencies
====================== ======================
The command :option:`mopidy --show-deps` will list the paths to and versions of The command ``mopidy deps`` will list the paths to and versions of
any dependency Mopidy or the extensions might need to work. This is very useful any dependency Mopidy or the extensions might need to work. This is very useful
data for checking that you're using the right versions, and that you're using data for checking that you're using the right versions, and that you're using
the right installation if you have multiple installations of a dependency on the right installation if you have multiple installations of a dependency on

View File

@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring)
warnings.filterwarnings('ignore', 'could not open display') warnings.filterwarnings('ignore', 'could not open display')
__version__ = '0.16.1' __version__ = '0.17.0'

View File

@ -8,6 +8,14 @@ import sys
import gobject import gobject
gobject.threads_init() gobject.threads_init()
try:
# Make GObject's mainloop the event loop for python-dbus
import dbus.mainloop.glib
dbus.mainloop.glib.threads_init()
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
except ImportError:
pass
import pykka.debug import pykka.debug
@ -18,78 +26,119 @@ sys.argv[1:] = []
from mopidy import commands, ext from mopidy import commands, ext
from mopidy.audio import Audio
from mopidy import config as config_lib from mopidy import config as config_lib
from mopidy.core import Core from mopidy.utils import log, path, process, versioning
from mopidy.utils import log, path, process
logger = logging.getLogger('mopidy.main') logger = logging.getLogger('mopidy.main')
def main(): def main():
log.bootstrap_delayed_logging()
logger.info('Starting Mopidy %s', versioning.get_version())
signal.signal(signal.SIGTERM, process.exit_handler) signal.signal(signal.SIGTERM, process.exit_handler)
signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks) signal.signal(signal.SIGUSR1, pykka.debug.log_thread_tracebacks)
args = commands.parser.parse_args(args=mopidy_args)
if args.show_config:
commands.show_config(args)
if args.show_deps:
commands.show_deps()
# TODO: figure out a way to make the boilerplate in this file reusable in
# scanner and other places we need it.
try: try:
# Initial config without extensions to bootstrap logging. root_cmd = commands.RootCommand()
logging_initialized = False config_cmd = commands.ConfigCommand()
logging_config, _ = config_lib.load( deps_cmd = commands.DepsCommand()
args.config_files, [], args.config_overrides)
# TODO: setup_logging needs defaults in-case config values are None root_cmd.set(extension=None)
log.setup_logging( root_cmd.add_child('config', config_cmd)
logging_config, args.verbosity_level, args.save_debug_log) root_cmd.add_child('deps', deps_cmd)
logging_initialized = True
create_file_structures()
check_old_locations()
installed_extensions = ext.load_extensions() installed_extensions = ext.load_extensions()
for extension in installed_extensions:
ext_cmd = extension.get_command()
if ext_cmd:
ext_cmd.set(extension=extension)
root_cmd.add_child(extension.ext_name, ext_cmd)
args = root_cmd.parse(mopidy_args)
create_file_structures_and_config(args, installed_extensions)
check_old_locations()
config, config_errors = config_lib.load( config, config_errors = config_lib.load(
args.config_files, installed_extensions, args.config_overrides) args.config_files, installed_extensions, args.config_overrides)
# Filter out disabled extensions and remove any config errors for them. verbosity_level = args.base_verbosity_level
if args.verbosity_level:
verbosity_level += args.verbosity_level
log.setup_logging(config, verbosity_level, args.save_debug_log)
enabled_extensions = [] enabled_extensions = []
for extension in installed_extensions: for extension in installed_extensions:
enabled = config[extension.ext_name]['enabled'] if not ext.validate_extension(extension):
if ext.validate_extension(extension) and enabled: config[extension.ext_name] = {'enabled': False}
config_errors[extension.ext_name] = {
'enabled': 'extension disabled by self check.'}
elif not config[extension.ext_name]['enabled']:
config[extension.ext_name] = {'enabled': False}
config_errors[extension.ext_name] = {
'enabled': 'extension disabled by user config.'}
else:
enabled_extensions.append(extension) enabled_extensions.append(extension)
elif extension.ext_name in config_errors:
del config_errors[extension.ext_name]
log_extension_info(installed_extensions, enabled_extensions) log_extension_info(installed_extensions, enabled_extensions)
ext.register_gstreamer_elements(enabled_extensions)
# Config and deps commands are simply special cased for now.
if args.command == config_cmd:
return args.command.run(
config, config_errors, installed_extensions)
elif args.command == deps_cmd:
return args.command.run()
# Remove errors for extensions that are not enabled:
for extension in installed_extensions:
if extension not in enabled_extensions:
config_errors.pop(extension.ext_name, None)
check_config_errors(config_errors) check_config_errors(config_errors)
# Read-only config from here on, please. # Read-only config from here on, please.
proxied_config = config_lib.Proxy(config) proxied_config = config_lib.Proxy(config)
log.setup_log_levels(proxied_config) if args.extension and args.extension not in enabled_extensions:
ext.register_gstreamer_elements(enabled_extensions) logger.error(
'Unable to run command provided by disabled extension %s',
args.extension.ext_name)
return 1
# Anything that wants to exit after this point must use # Anything that wants to exit after this point must use
# mopidy.utils.process.exit_process as actors have been started. # mopidy.utils.process.exit_process as actors can have been started.
start(proxied_config, enabled_extensions) try:
return args.command.run(args, proxied_config, enabled_extensions)
except NotImplementedError:
print root_cmd.format_help()
return 1
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
except Exception as ex: except Exception as ex:
if logging_initialized: logger.exception(ex)
logger.exception(ex)
raise raise
def create_file_structures(): def create_file_structures_and_config(args, extensions):
path.get_or_create_dir(b'$XDG_DATA_DIR/mopidy') path.get_or_create_dir(b'$XDG_DATA_DIR/mopidy')
path.get_or_create_file(b'$XDG_CONFIG_DIR/mopidy/mopidy.conf') path.get_or_create_dir(b'$XDG_CONFIG_DIR/mopidy')
# Initialize whatever the last config file is with defaults
config_file = args.config_files[-1]
if os.path.exists(config_file):
return
try:
default = config_lib.format_initial(extensions)
path.get_or_create_file(config_file, mkdir=False, content=default)
logger.info('Initialized %s with default config', config_file)
except IOError as e:
logger.warning('Unable to initialize %s with default config: %s',
config_file, e)
def check_old_locations(): def check_old_locations():
@ -127,89 +176,5 @@ def check_config_errors(errors):
sys.exit(1) sys.exit(1)
def start(config, extensions):
loop = gobject.MainLoop()
try:
audio = start_audio(config)
backends = start_backends(config, extensions, audio)
core = start_core(audio, backends)
start_frontends(config, extensions, core)
loop.run()
except KeyboardInterrupt:
logger.info('Interrupted. Exiting...')
return
finally:
loop.quit()
stop_frontends(extensions)
stop_core()
stop_backends(extensions)
stop_audio()
process.stop_remaining_actors()
def start_audio(config):
logger.info('Starting Mopidy audio')
return Audio.start(config=config).proxy()
def stop_audio():
logger.info('Stopping Mopidy audio')
process.stop_actors_by_class(Audio)
def start_backends(config, extensions, audio):
backend_classes = []
for extension in extensions:
backend_classes.extend(extension.get_backend_classes())
logger.info(
'Starting Mopidy backends: %s',
', '.join(b.__name__ for b in backend_classes) or 'none')
backends = []
for backend_class in backend_classes:
backend = backend_class.start(config=config, audio=audio).proxy()
backends.append(backend)
return backends
def stop_backends(extensions):
logger.info('Stopping Mopidy backends')
for extension in extensions:
for backend_class in extension.get_backend_classes():
process.stop_actors_by_class(backend_class)
def start_core(audio, backends):
logger.info('Starting Mopidy core')
return Core.start(audio=audio, backends=backends).proxy()
def stop_core():
logger.info('Stopping Mopidy core')
process.stop_actors_by_class(Core)
def start_frontends(config, extensions, core):
frontend_classes = []
for extension in extensions:
frontend_classes.extend(extension.get_frontend_classes())
logger.info(
'Starting Mopidy frontends: %s',
', '.join(f.__name__ for f in frontend_classes) or 'none')
for frontend_class in frontend_classes:
frontend_class.start(config=config, core=core)
def stop_frontends(extensions):
logger.info('Stopping Mopidy frontends')
for extension in extensions:
for frontend_class in extension.get_frontend_classes():
process.stop_actors_by_class(frontend_class)
if __name__ == '__main__': if __name__ == '__main__':
main() sys.exit(main())

View File

@ -1,9 +1,7 @@
"""Mixer element that automatically selects the real mixer to use. """Mixer element that automatically selects the real mixer to use.
This is Mopidy's default mixer. Set the :confval:`audio/mixer` config value to ``autoaudiomixer`` to use this
mixer.
If this wasn't the default, you would set the :confval:`audio/mixer` config
value to ``autoaudiomixer`` to use this mixer.
""" """
from __future__ import unicode_literals from __future__ import unicode_literals

193
mopidy/audio/scan.py Normal file
View File

@ -0,0 +1,193 @@
from __future__ import unicode_literals
import pygst
pygst.require('0.10')
import gst
import datetime
import os
import time
from mopidy import exceptions
from mopidy.models import Track, Artist, Album
from mopidy.utils import path
class Scanner(object):
"""
Helper to get tags and other relevant info from URIs.
:param timeout: timeout for scanning a URI in ms
:type event: int
:param min_duration: minimum duration of scanned URI in ms, -1 for all.
:type event: int
"""
def __init__(self, timeout=1000, min_duration=100):
self.timeout_ms = timeout
self.min_duration_ms = min_duration
sink = gst.element_factory_make('fakesink')
audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
pad_added = lambda src, pad: pad.link(sink.get_pad('sink'))
self.uribin = gst.element_factory_make('uridecodebin')
self.uribin.set_property('caps', audio_caps)
self.uribin.connect('pad-added', pad_added)
self.pipe = gst.element_factory_make('pipeline')
self.pipe.add(self.uribin)
self.pipe.add(sink)
self.bus = self.pipe.get_bus()
self.bus.set_flushing(True)
def scan(self, uri):
"""
Scan the given uri collecting relevant metadata.
:param uri: URI of the resource to scan.
:type event: string
:return: Dictionary of tags, duration, mtime and uri information.
"""
try:
self._setup(uri)
data = self._collect()
# Make sure uri and duration does not come from tags.
data[b'uri'] = uri
data[b'mtime'] = self._query_mtime(uri)
data[gst.TAG_DURATION] = self._query_duration()
finally:
self._reset()
if data[gst.TAG_DURATION] < self.min_duration_ms * gst.MSECOND:
raise exceptions.ScannerError('Rejecting file with less than %dms '
'audio data.' % self.min_duration_ms)
return data
def _setup(self, uri):
"""Primes the pipeline for collection."""
self.pipe.set_state(gst.STATE_READY)
self.uribin.set_property(b'uri', uri)
self.bus.set_flushing(False)
self.pipe.set_state(gst.STATE_PAUSED)
def _collect(self):
"""Polls for messages to collect data."""
start = time.time()
timeout_s = self.timeout_ms / float(1000)
poll_timeout_ns = 1000
data = {}
while time.time() - start < timeout_s:
message = self.bus.poll(gst.MESSAGE_ANY, poll_timeout_ns)
if message is None:
pass # polling the bus timed out.
elif message.type == gst.MESSAGE_ERROR:
raise exceptions.ScannerError(message.parse_error()[0])
elif message.type == gst.MESSAGE_EOS:
return data
elif message.type == gst.MESSAGE_ASYNC_DONE:
if message.src == self.pipe:
return data
elif message.type == gst.MESSAGE_TAG:
taglist = message.parse_tag()
for key in taglist.keys():
data[key] = taglist[key]
raise exceptions.ScannerError('Timeout after %dms' % self.timeout_ms)
def _reset(self):
"""Ensures we cleanup child elements and flush the bus."""
self.bus.set_flushing(True)
self.pipe.set_state(gst.STATE_NULL)
def _query_duration(self):
try:
return self.pipe.query_duration(gst.FORMAT_TIME, None)[0]
except gst.QueryError:
return None
def _query_mtime(self, uri):
if not uri.startswith('file:'):
return None
return os.path.getmtime(path.uri_to_path(uri))
def audio_data_to_track(data):
"""Convert taglist data + our extras to a track."""
albumartist_kwargs = {}
album_kwargs = {}
artist_kwargs = {}
composer_kwargs = {}
performer_kwargs = {}
track_kwargs = {}
def _retrieve(source_key, target_key, target):
if source_key in data:
target[target_key] = data[source_key]
_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)
_retrieve(gst.TAG_COMPOSER, 'name', composer_kwargs)
_retrieve(gst.TAG_PERFORMER, 'name', performer_kwargs)
_retrieve(gst.TAG_ALBUM_ARTIST, 'name', albumartist_kwargs)
_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)
_retrieve(gst.TAG_GENRE, 'genre', track_kwargs)
_retrieve(gst.TAG_BITRATE, 'bitrate', track_kwargs)
# Following keys don't seem to have TAG_* constant.
_retrieve('comment', 'comment', track_kwargs)
_retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs)
_retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs)
_retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs)
_retrieve(
'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs)
if gst.TAG_DATE in data and data[gst.TAG_DATE]:
date = data[gst.TAG_DATE]
try:
date = datetime.date(date.year, date.month, date.day)
except ValueError:
pass # Ignore invalid dates
else:
track_kwargs['date'] = date.isoformat()
if albumartist_kwargs:
album_kwargs['artists'] = [Artist(**albumartist_kwargs)]
track_kwargs['uri'] = data['uri']
track_kwargs['last_modified'] = int(data['mtime'])
track_kwargs['length'] = data[gst.TAG_DURATION] // gst.MSECOND
track_kwargs['album'] = Album(**album_kwargs)
if ('name' in artist_kwargs
and not isinstance(artist_kwargs['name'], basestring)):
track_kwargs['artists'] = [Artist(name=artist)
for artist in artist_kwargs['name']]
else:
track_kwargs['artists'] = [Artist(**artist_kwargs)]
if ('name' in composer_kwargs
and not isinstance(composer_kwargs['name'], basestring)):
track_kwargs['composers'] = [Artist(name=artist)
for artist in composer_kwargs['name']]
else:
track_kwargs['composers'] = \
[Artist(**composer_kwargs)] if composer_kwargs else ''
if ('name' in performer_kwargs
and not isinstance(performer_kwargs['name'], basestring)):
track_kwargs['performers'] = [Artist(name=artist)
for artist in performer_kwargs['name']]
else:
track_kwargs['performers'] = \
[Artist(**performer_kwargs)] if performer_kwargs else ''
return Track(**track_kwargs)

View File

@ -36,3 +36,7 @@ class Extension(ext.Extension):
def get_library_updaters(self): def get_library_updaters(self):
from .library import LocalLibraryUpdateProvider from .library import LocalLibraryUpdateProvider
return [LocalLibraryUpdateProvider] return [LocalLibraryUpdateProvider]
def get_command(self):
from .commands import LocalCommand
return LocalCommand()

View File

@ -0,0 +1,115 @@
from __future__ import unicode_literals
import logging
import os
import time
from mopidy import commands, exceptions
from mopidy.audio import scan
from mopidy.utils import path
from . import translator
logger = logging.getLogger('mopidy.backends.local.commands')
class LocalCommand(commands.Command):
def __init__(self):
super(LocalCommand, self).__init__()
self.add_child('scan', ScanCommand())
class ScanCommand(commands.Command):
help = "Scan local media files and populate the local library."
def run(self, args, config, extensions):
media_dir = config['local']['media_dir']
scan_timeout = config['local']['scan_timeout']
excluded_file_extensions = set(
ext.lower() for ext in config['local']['excluded_file_extensions'])
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:
logger.error('No usable library updaters found.')
return 1
elif len(updaters) > 1:
logger.error('More than one library updater found. '
'Provided by: %s', ', '.join(updaters.keys()))
return 1
local_updater = updaters.values()[0](config)
# TODO: cleanup to consistently use local urls, not a random mix of
# local and file uris depending on how the data was loaded.
uris_library = set()
uris_update = set()
uris_remove = set()
tracks = local_updater.load()
logger.info('Checking %d tracks from library.', len(tracks))
for track in tracks:
try:
uri = translator.local_to_file_uri(track.uri, media_dir)
stat = os.stat(path.uri_to_path(uri))
if int(stat.st_mtime) > track.last_modified:
uris_update.add(uri)
uris_library.add(uri)
except OSError:
logger.debug('Missing file %s', track.uri)
uris_remove.add(track.uri)
logger.info('Removing %d missing tracks.', len(uris_remove))
for uri in uris_remove:
local_updater.remove(uri)
logger.info('Checking %s for unknown tracks.', media_dir)
for uri in path.find_uris(media_dir):
file_extension = os.path.splitext(path.uri_to_path(uri))[1]
if file_extension.lower() in excluded_file_extensions:
logger.debug('Skipped %s: File extension excluded.', uri)
continue
if uri not in uris_library:
uris_update.add(uri)
logger.info('Found %d unknown tracks.', len(uris_update))
logger.info('Scanning...')
scanner = scan.Scanner(scan_timeout)
progress = Progress(len(uris_update))
for uri in sorted(uris_update):
try:
data = scanner.scan(uri)
track = scan.audio_data_to_track(data)
local_updater.add(track)
logger.debug('Added %s', track.uri)
except exceptions.ScannerError as error:
logger.warning('Failed %s: %s', uri, error)
progress.increment()
logger.info('Commiting changes.')
local_updater.commit()
return 0
# TODO: move to utils?
class Progress(object):
def __init__(self, total):
self.count = 0
self.total = total
self.start = time.time()
def increment(self):
self.count += 1
if self.count % 1000 == 0 or self.count == self.total:
duration = time.time() - self.start
remainder = duration / self.count * (self.total - self.count)
logger.info('Scanned %d of %d files in %ds, ~%ds left.',
self.count, self.total, duration, remainder)

View File

@ -5,8 +5,10 @@ playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists
tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache tag_cache_file = $XDG_DATA_DIR/mopidy/local/tag_cache
scan_timeout = 1000 scan_timeout = 1000
excluded_file_extensions = excluded_file_extensions =
.html
.jpeg .jpeg
.jpg .jpg
.log
.nfo
.png .png
.txt .txt
.log

View File

@ -8,7 +8,7 @@ from mopidy.backends import base
from mopidy.frontends.mpd import translator as mpd_translator from mopidy.frontends.mpd import translator as mpd_translator
from mopidy.models import Album, SearchResult from mopidy.models import Album, SearchResult
from .translator import parse_mpd_tag_cache from .translator import local_to_file_uri, parse_mpd_tag_cache
logger = logging.getLogger('mopidy.backends.local') logger = logging.getLogger('mopidy.backends.local')
@ -72,37 +72,58 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
q = value.strip() q = value.strip()
uri_filter = lambda t: q == t.uri uri_filter = lambda t: q == t.uri
track_filter = lambda t: q == t.name track_name_filter = lambda t: q == t.name
album_filter = lambda t: q == getattr(t, 'album', Album()).name album_filter = lambda t: q == getattr(t, 'album', Album()).name
artist_filter = lambda t: filter( artist_filter = lambda t: filter(
lambda a: q == a.name, t.artists) lambda a: q == a.name, t.artists)
albumartist_filter = lambda t: any([ albumartist_filter = lambda t: any([
q == a.name q == a.name
for a in getattr(t.album, 'artists', [])]) for a in getattr(t.album, 'artists', [])])
composer_filter = lambda t: any([
q == a.name
for a in getattr(t, 'composers', [])])
performer_filter = lambda t: any([
q == a.name
for a in getattr(t, 'performers', [])])
track_no_filter = lambda t: q == t.track_no track_no_filter = lambda t: q == t.track_no
genre_filter = lambda t: t.genre and q == t.genre
date_filter = lambda t: q == t.date date_filter = lambda t: q == t.date
comment_filter = lambda t: q == t.comment
any_filter = lambda t: ( any_filter = lambda t: (
uri_filter(t) or uri_filter(t) or
track_filter(t) or track_name_filter(t) or
album_filter(t) or album_filter(t) or
artist_filter(t) or artist_filter(t) or
albumartist_filter(t) or albumartist_filter(t) or
date_filter(t)) composer_filter(t) or
performer_filter(t) or
track_no_filter(t) or
genre_filter(t) or
date_filter(t) or
comment_filter(t))
if field == 'uri': if field == 'uri':
result_tracks = filter(uri_filter, result_tracks) result_tracks = filter(uri_filter, result_tracks)
elif field == 'track': elif field == 'track_name':
result_tracks = filter(track_filter, result_tracks) result_tracks = filter(track_name_filter, result_tracks)
elif field == 'album': elif field == 'album':
result_tracks = filter(album_filter, result_tracks) result_tracks = filter(album_filter, result_tracks)
elif field == 'artist': elif field == 'artist':
result_tracks = filter(artist_filter, result_tracks) result_tracks = filter(artist_filter, result_tracks)
elif field == 'albumartist': elif field == 'albumartist':
result_tracks = filter(albumartist_filter, result_tracks) result_tracks = filter(albumartist_filter, result_tracks)
elif field == 'composer':
result_tracks = filter(composer_filter, result_tracks)
elif field == 'performer':
result_tracks = filter(performer_filter, result_tracks)
elif field == 'track_no': elif field == 'track_no':
result_tracks = filter(track_no_filter, result_tracks) result_tracks = filter(track_no_filter, result_tracks)
elif field == 'genre':
result_tracks = filter(genre_filter, result_tracks)
elif field == 'date': elif field == 'date':
result_tracks = filter(date_filter, result_tracks) result_tracks = filter(date_filter, result_tracks)
elif field == 'comment':
result_tracks = filter(comment_filter, result_tracks)
elif field == 'any': elif field == 'any':
result_tracks = filter(any_filter, result_tracks) result_tracks = filter(any_filter, result_tracks)
else: else:
@ -129,7 +150,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
q = value.strip().lower() q = value.strip().lower()
uri_filter = lambda t: q in t.uri.lower() uri_filter = lambda t: q in t.uri.lower()
track_filter = lambda t: q in t.name.lower() track_name_filter = lambda t: q in t.name.lower()
album_filter = lambda t: q in getattr( album_filter = lambda t: q in getattr(
t, 'album', Album()).name.lower() t, 'album', Album()).name.lower()
artist_filter = lambda t: filter( artist_filter = lambda t: filter(
@ -137,30 +158,51 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
albumartist_filter = lambda t: any([ albumartist_filter = lambda t: any([
q in a.name.lower() q in a.name.lower()
for a in getattr(t.album, 'artists', [])]) for a in getattr(t.album, 'artists', [])])
composer_filter = lambda t: any([
q in a.name.lower()
for a in getattr(t, 'composers', [])])
performer_filter = lambda t: any([
q in a.name.lower()
for a in getattr(t, 'performers', [])])
track_no_filter = lambda t: q == t.track_no track_no_filter = lambda t: q == t.track_no
genre_filter = lambda t: t.genre and q in t.genre.lower()
date_filter = lambda t: t.date and t.date.startswith(q) date_filter = lambda t: t.date and t.date.startswith(q)
comment_filter = lambda t: t.comment and q in t.comment.lower()
any_filter = lambda t: ( any_filter = lambda t: (
uri_filter(t) or uri_filter(t) or
track_filter(t) or track_name_filter(t) or
album_filter(t) or album_filter(t) or
artist_filter(t) or artist_filter(t) or
albumartist_filter(t) or albumartist_filter(t) or
date_filter(t)) composer_filter(t) or
performer_filter(t) or
track_no_filter(t) or
genre_filter(t) or
date_filter(t) or
comment_filter(t))
if field == 'uri': if field == 'uri':
result_tracks = filter(uri_filter, result_tracks) result_tracks = filter(uri_filter, result_tracks)
elif field == 'track': elif field == 'track_name':
result_tracks = filter(track_filter, result_tracks) result_tracks = filter(track_name_filter, result_tracks)
elif field == 'album': elif field == 'album':
result_tracks = filter(album_filter, result_tracks) result_tracks = filter(album_filter, result_tracks)
elif field == 'artist': elif field == 'artist':
result_tracks = filter(artist_filter, result_tracks) result_tracks = filter(artist_filter, result_tracks)
elif field == 'albumartist': elif field == 'albumartist':
result_tracks = filter(albumartist_filter, result_tracks) result_tracks = filter(albumartist_filter, result_tracks)
elif field == 'composer':
result_tracks = filter(composer_filter, result_tracks)
elif field == 'performer':
result_tracks = filter(performer_filter, result_tracks)
elif field == 'track_no': elif field == 'track_no':
result_tracks = filter(track_no_filter, result_tracks) result_tracks = filter(track_no_filter, result_tracks)
elif field == 'genre':
result_tracks = filter(genre_filter, result_tracks)
elif field == 'date': elif field == 'date':
result_tracks = filter(date_filter, result_tracks) result_tracks = filter(date_filter, result_tracks)
elif field == 'comment':
result_tracks = filter(comment_filter, result_tracks)
elif field == 'any': elif field == 'any':
result_tracks = filter(any_filter, result_tracks) result_tracks = filter(any_filter, result_tracks)
else: else:
@ -189,7 +231,10 @@ class LocalLibraryUpdateProvider(base.BaseLibraryProvider):
def load(self): def load(self):
tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir) tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir)
for track in tracks: for track in tracks:
self._tracks[track.uri] = track # TODO: this should use uris as is, i.e. hack that should go away
# with tag caches.
uri = local_to_file_uri(track.uri, self._media_dir)
self._tracks[uri] = track.copy(uri=uri)
return tracks return tracks
def add(self, track): def add(self, track):

View File

@ -1,10 +1,10 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import logging import logging
import os
from mopidy.backends import base from mopidy.backends import base
from mopidy.utils import path
from . import translator
logger = logging.getLogger('mopidy.backends.local') logger = logging.getLogger('mopidy.backends.local')
@ -12,8 +12,6 @@ logger = logging.getLogger('mopidy.backends.local')
class LocalPlaybackProvider(base.BasePlaybackProvider): class LocalPlaybackProvider(base.BasePlaybackProvider):
def change_track(self, track): def change_track(self, track):
media_dir = self.backend.config['local']['media_dir'] media_dir = self.backend.config['local']['media_dir']
# TODO: check that type is correct. uri = translator.local_to_file_uri(track.uri, media_dir)
file_path = path.uri_to_path(track.uri).split(b':', 1)[1] track = track.copy(uri=uri)
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) return super(LocalPlaybackProvider, self).change_track(track)

View File

@ -6,11 +6,18 @@ import urlparse
from mopidy.models import Track, Artist, Album from mopidy.models import Track, Artist, Album
from mopidy.utils.encoding import locale_decode from mopidy.utils.encoding import locale_decode
from mopidy.utils.path import path_to_uri from mopidy.utils.path import path_to_uri, uri_to_path
logger = logging.getLogger('mopidy.backends.local') logger = logging.getLogger('mopidy.backends.local')
def local_to_file_uri(uri, media_dir):
# TODO: check that type is correct.
file_path = uri_to_path(uri).split(b':', 1)[1]
file_path = os.path.join(media_dir, file_path)
return path_to_uri(file_path)
def parse_m3u(file_path, media_dir): def parse_m3u(file_path, media_dir):
r""" r"""
Convert M3U file list of uris Convert M3U file list of uris
@ -120,20 +127,31 @@ def _convert_mpd_data(data, tracks):
if 'artist' in data: if 'artist' in data:
artist_kwargs['name'] = data['artist'] artist_kwargs['name'] = data['artist']
albumartist_kwargs['name'] = data['artist']
if 'albumartist' in data: if 'albumartist' in data:
albumartist_kwargs['name'] = data['albumartist'] albumartist_kwargs['name'] = data['albumartist']
if 'composer' in data:
track_kwargs['composers'] = [Artist(name=data['composer'])]
if 'performer' in data:
track_kwargs['performers'] = [Artist(name=data['performer'])]
if 'album' in data: if 'album' in data:
album_kwargs['name'] = data['album'] album_kwargs['name'] = data['album']
if 'title' in data: if 'title' in data:
track_kwargs['name'] = data['title'] track_kwargs['name'] = data['title']
if 'genre' in data:
track_kwargs['genre'] = data['genre']
if 'date' in data: if 'date' in data:
track_kwargs['date'] = data['date'] track_kwargs['date'] = data['date']
if 'comment' in data:
track_kwargs['comment'] = data['comment']
if 'musicbrainz_trackid' in data: if 'musicbrainz_trackid' in data:
track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid'] track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid']

View File

@ -1,10 +1,19 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import argparse import argparse
import collections
import logging
import os
import sys import sys
from mopidy import config as config_lib, ext import gobject
from mopidy.utils import deps, versioning
from mopidy import config as config_lib
from mopidy.audio import Audio
from mopidy.core import Core
from mopidy.utils import deps, process, versioning
logger = logging.getLogger('mopidy.commands')
def config_files_type(value): def config_files_type(value):
@ -21,63 +30,322 @@ def config_override_type(value):
'%s must have the format section/key=value' % value) '%s must have the format section/key=value' % value)
parser = argparse.ArgumentParser() class _ParserError(Exception):
parser.add_argument( pass
'--version', action='version',
version='Mopidy %s' % versioning.get_version())
parser.add_argument(
'-q', '--quiet',
action='store_const', const=-1, dest='verbosity_level',
help='less output (warning level)')
parser.add_argument(
'-v', '--verbose',
action='count', dest='verbosity_level',
help='more output (debug level)')
parser.add_argument(
'--save-debug-log',
action='store_true', dest='save_debug_log',
help='save debug log to "./mopidy.log"')
parser.add_argument(
'--show-config',
action='store_true', dest='show_config',
help='show current config')
parser.add_argument(
'--show-deps',
action='store_true', dest='show_deps',
help='show dependencies and their versions')
parser.add_argument(
'--config',
action='store', dest='config_files', type=config_files_type,
default=b'$XDG_CONFIG_DIR/mopidy/mopidy.conf',
help='config files to use, colon seperated, later files override')
parser.add_argument(
'-o', '--option',
action='append', dest='config_overrides', type=config_override_type,
help='`section/key=value` values to override config options')
def show_config(args): class _HelpError(Exception):
"""Prints the effective config and exits.""" pass
extensions = ext.load_extensions()
config, errors = config_lib.load(
args.config_files, extensions, args.config_overrides)
# Clear out any config for disabled extensions.
for extension in extensions:
if not ext.validate_extension(extension):
config[extension.ext_name] = {b'enabled': False}
errors[extension.ext_name] = {
b'enabled': b'extension disabled itself.'}
elif not config[extension.ext_name]['enabled']:
config[extension.ext_name] = {b'enabled': False}
errors[extension.ext_name] = {
b'enabled': b'extension disabled by config.'}
print config_lib.format(config, extensions, errors)
sys.exit(0)
def show_deps(): class _ArgumentParser(argparse.ArgumentParser):
"""Prints a list of all dependencies and exits.""" def error(self, message):
print deps.format_dependency_list() raise _ParserError(message)
sys.exit(0)
class _HelpAction(argparse.Action):
def __init__(self, option_strings, dest=None, help=None):
super(_HelpAction, self).__init__(
option_strings=option_strings,
dest=dest or argparse.SUPPRESS,
default=argparse.SUPPRESS,
nargs=0,
help=help)
def __call__(self, parser, namespace, values, option_string=None):
raise _HelpError()
class Command(object):
"""Command parser and runner for building trees of commands.
This class provides a wraper around :class:`argparse.ArgumentParser`
for handling this type of command line application in a better way than
argprases own sub-parser handling.
"""
help = None
#: Help text to display in help output.
def __init__(self):
self._children = collections.OrderedDict()
self._arguments = []
self._overrides = {}
def _build(self):
actions = []
parser = _ArgumentParser(add_help=False)
parser.register('action', 'help', _HelpAction)
for args, kwargs in self._arguments:
actions.append(parser.add_argument(*args, **kwargs))
parser.add_argument('_args', nargs=argparse.REMAINDER,
help=argparse.SUPPRESS)
return parser, actions
def add_child(self, name, command):
"""Add a child parser to consider using.
:param name: name to use for the sub-command that is being added.
:type name: string
"""
self._children[name] = command
def add_argument(self, *args, **kwargs):
"""Add am argument to the parser.
This method takes all the same arguments as the
:class:`argparse.ArgumentParser` version of this method.
"""
self._arguments.append((args, kwargs))
def set(self, **kwargs):
"""Override a value in the finaly result of parsing."""
self._overrides.update(kwargs)
def exit(self, status_code=0, message=None, usage=None):
"""Optionally print a message and exit."""
print '\n\n'.join(m for m in (usage, message) if m)
sys.exit(status_code)
def format_usage(self, prog=None):
"""Format usage for current parser."""
actions = self._build()[1]
prog = prog or os.path.basename(sys.argv[0])
return self._usage(actions, prog) + '\n'
def _usage(self, actions, prog):
formatter = argparse.HelpFormatter(prog)
formatter.add_usage(None, actions, [])
return formatter.format_help().strip()
def format_help(self, prog=None):
"""Format help for current parser and children."""
actions = self._build()[1]
prog = prog or os.path.basename(sys.argv[0])
formatter = argparse.HelpFormatter(prog)
formatter.add_usage(None, actions, [])
if self.help:
formatter.add_text(self.help)
if actions:
formatter.add_text('OPTIONS:')
formatter.start_section(None)
formatter.add_arguments(actions)
formatter.end_section()
subhelp = []
for name, child in self._children.items():
child._subhelp(name, subhelp)
if subhelp:
formatter.add_text('COMMANDS:')
subhelp.insert(0, '')
return formatter.format_help() + '\n'.join(subhelp)
def _subhelp(self, name, result):
actions = self._build()[1]
if self.help or actions:
formatter = argparse.HelpFormatter(name)
formatter.add_usage(None, actions, [], '')
formatter.start_section(None)
formatter.add_text(self.help)
formatter.start_section(None)
formatter.add_arguments(actions)
formatter.end_section()
formatter.end_section()
result.append(formatter.format_help())
for childname, child in self._children.items():
child._subhelp(' '.join((name, childname)), result)
def parse(self, args, prog=None):
"""Parse command line arguments.
Will recursively parse commands until a final parser is found or an
error occurs. In the case of errors we will print a message and exit.
Otherwise, any overrides are applied and the current parser stored
in the command attribute of the return value.
:param args: list of arguments to parse
:type args: list of strings
:param prog: name to use for program
:type prog: string
:rtype: :class:`argparse.Namespace`
"""
prog = prog or os.path.basename(sys.argv[0])
try:
return self._parse(
args, argparse.Namespace(), self._overrides.copy(), prog)
except _HelpError:
self.exit(0, self.format_help(prog))
def _parse(self, args, namespace, overrides, prog):
overrides.update(self._overrides)
parser, actions = self._build()
try:
result = parser.parse_args(args, namespace)
except _ParserError as e:
self.exit(1, e.message, self._usage(actions, prog))
if not result._args:
for attr, value in overrides.items():
setattr(result, attr, value)
delattr(result, '_args')
result.command = self
return result
child = result._args.pop(0)
if child not in self._children:
usage = self._usage(actions, prog)
self.exit(1, 'unrecognized command: %s' % child, usage)
return self._children[child]._parse(
result._args, result, overrides, ' '.join([prog, child]))
def run(self, *args, **kwargs):
"""Run the command.
Must be implemented by sub-classes that are not simply and intermediate
in the command namespace.
"""
raise NotImplementedError
class RootCommand(Command):
def __init__(self):
super(RootCommand, self).__init__()
self.set(base_verbosity_level=0)
self.add_argument(
'-h', '--help',
action='help', help='Show this message and exit')
self.add_argument(
'--version', action='version',
version='Mopidy %s' % versioning.get_version())
self.add_argument(
'-q', '--quiet',
action='store_const', const=-1, dest='verbosity_level',
help='less output (warning level)')
self.add_argument(
'-v', '--verbose',
action='count', dest='verbosity_level', default=0,
help='more output (debug level)')
self.add_argument(
'--save-debug-log',
action='store_true', dest='save_debug_log',
help='save debug log to "./mopidy.log"')
self.add_argument(
'--config',
action='store', dest='config_files', type=config_files_type,
default=b'$XDG_CONFIG_DIR/mopidy/mopidy.conf', metavar='FILES',
help='config files to use, colon seperated, later files override')
self.add_argument(
'-o', '--option',
action='append', dest='config_overrides',
type=config_override_type, metavar='OPTIONS',
help='`section/key=value` values to override config options')
def run(self, args, config, extensions):
loop = gobject.MainLoop()
try:
audio = self.start_audio(config)
backends = self.start_backends(config, extensions, audio)
core = self.start_core(audio, backends)
self.start_frontends(config, extensions, core)
loop.run()
except KeyboardInterrupt:
logger.info('Interrupted. Exiting...')
return
finally:
loop.quit()
self.stop_frontends(extensions)
self.stop_core()
self.stop_backends(extensions)
self.stop_audio()
process.stop_remaining_actors()
def start_audio(self, config):
logger.info('Starting Mopidy audio')
return Audio.start(config=config).proxy()
def start_backends(self, config, extensions, audio):
backend_classes = []
for extension in extensions:
backend_classes.extend(extension.get_backend_classes())
logger.info(
'Starting Mopidy backends: %s',
', '.join(b.__name__ for b in backend_classes) or 'none')
backends = []
for backend_class in backend_classes:
backend = backend_class.start(config=config, audio=audio).proxy()
backends.append(backend)
return backends
def start_core(self, audio, backends):
logger.info('Starting Mopidy core')
return Core.start(audio=audio, backends=backends).proxy()
def start_frontends(self, config, extensions, core):
frontend_classes = []
for extension in extensions:
frontend_classes.extend(extension.get_frontend_classes())
logger.info(
'Starting Mopidy frontends: %s',
', '.join(f.__name__ for f in frontend_classes) or 'none')
for frontend_class in frontend_classes:
frontend_class.start(config=config, core=core)
def stop_frontends(self, extensions):
logger.info('Stopping Mopidy frontends')
for extension in extensions:
for frontend_class in extension.get_frontend_classes():
process.stop_actors_by_class(frontend_class)
def stop_core(self):
logger.info('Stopping Mopidy core')
process.stop_actors_by_class(Core)
def stop_backends(self, extensions):
logger.info('Stopping Mopidy backends')
for extension in extensions:
for backend_class in extension.get_backend_classes():
process.stop_actors_by_class(backend_class)
def stop_audio(self):
logger.info('Stopping Mopidy audio')
process.stop_actors_by_class(Audio)
class ConfigCommand(Command):
help = 'Show currently active configuration.'
def __init__(self):
super(ConfigCommand, self).__init__()
self.set(base_verbosity_level=-1)
def run(self, config, errors, extensions):
print config_lib.format(config, extensions, errors)
return 0
class DepsCommand(Command):
help = 'Show dependencies and debug information.'
def __init__(self):
super(DepsCommand, self).__init__()
self.set(base_verbosity_level=-1)
def run(self):
print deps.format_dependency_list()
return 0

View File

@ -10,7 +10,7 @@ import re
from mopidy.config import keyring from mopidy.config import keyring
from mopidy.config.schemas import * # noqa from mopidy.config.schemas import * # noqa
from mopidy.config.types import * # noqa from mopidy.config.types import * # noqa
from mopidy.utils import path from mopidy.utils import path, versioning
logger = logging.getLogger('mopidy.config') logger = logging.getLogger('mopidy.config')
@ -41,6 +41,18 @@ _proxy_schema['password'] = Secret(optional=True)
_schemas = [_logging_schema, _loglevels_schema, _audio_schema, _proxy_schema] _schemas = [_logging_schema, _loglevels_schema, _audio_schema, _proxy_schema]
_INITIAL_HELP = """
# For further information about options in this file see:
# http://docs.mopidy.com/
#
# The initial commented out values reflect the defaults as of:
# %(versions)s
#
# Available options and defaults might have changed since then,
# run `mopidy config` to see the current effective config and
# `mopidy --version` to check the current version.
"""
def read(config_file): def read(config_file):
"""Helper to load config defaults in same way across core and extensions""" """Helper to load config defaults in same way across core and extensions"""
@ -66,7 +78,25 @@ def format(config, extensions, comments=None, display=True):
# need to know about extensions. # need to know about extensions.
schemas = _schemas[:] schemas = _schemas[:]
schemas.extend(e.get_config_schema() for e in extensions) schemas.extend(e.get_config_schema() for e in extensions)
return _format(config, comments or {}, schemas, display) return _format(config, comments or {}, schemas, display, False)
def format_initial(extensions):
config_dir = os.path.dirname(__file__)
defaults = [read(os.path.join(config_dir, 'default.conf'))]
defaults.extend(e.get_default_config() for e in extensions)
raw_config = _load([], defaults, [])
schemas = _schemas[:]
schemas.extend(e.get_config_schema() for e in extensions)
config, errors = _validate(raw_config, schemas)
versions = ['Mopidy %s' % versioning.get_version()]
for extension in sorted(extensions, key=lambda ext: ext.dist_name):
versions.append('%s %s' % (extension.dist_name, extension.version))
description = _INITIAL_HELP.strip() % {'versions': '\n# '.join(versions)}
return description + '\n\n' + _format(config, {}, schemas, False, True)
def _load(files, defaults, overrides): def _load(files, defaults, overrides):
@ -128,7 +158,7 @@ def _validate(raw_config, schemas):
return config, errors return config, errors
def _format(config, comments, schemas, display): def _format(config, comments, schemas, display, disable):
output = [] output = []
for schema in schemas: for schema in schemas:
serialized = schema.serialize( serialized = schema.serialize(
@ -142,9 +172,11 @@ def _format(config, comments, schemas, display):
if value is not None: if value is not None:
output[-1] += b' ' + value output[-1] += b' ' + value
if comment: if comment:
output[-1] += b' # ' + comment.capitalize() output[-1] += b' ; ' + comment.capitalize()
if disable:
output[-1] = re.sub(r'^', b'#', output[-1], flags=re.M)
output.append(b'') output.append(b'')
return b'\n'.join(output) return b'\n'.join(output).strip()
def _preprocess(config_string): def _preprocess(config_string):

View File

@ -8,7 +8,7 @@ config_file =
pykka = info pykka = info
[audio] [audio]
mixer = autoaudiomixer mixer = software
mixer_track = mixer_track =
output = autoaudiosink output = autoaudiosink
visualizer = visualizer =

View File

@ -1,5 +1,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import collections
import logging import logging
import random import random
@ -292,36 +293,51 @@ class TracklistController(object):
""" """
Filter the tracklist by the given criterias. Filter the tracklist by the given criterias.
A criteria consists of a model field to check and a list of values to
compare it against. If the model field matches one of the values, it
may be returned.
Only tracks that matches all the given criterias are returned.
Examples:: Examples::
# Returns track with TLID 7 (tracklist ID) # Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID)
filter({'tlid': 7}) filter({'tlid': [1, 2, 3, 4]})
filter(tlid=7) filter(tlid=[1, 2, 3, 4])
# Returns track with ID 1 # Returns track with IDs 1, 5, or 7
filter({'id': 1}) filter({'id': [1, 5, 7]})
filter(id=1) filter(id=[1, 5, 7])
# Returns track with URI 'xyz' # Returns track with URIs 'xyz' or 'abc'
filter({'uri': 'xyz'}) filter({'uri': ['xyz', 'abc']})
filter(uri='xyz') filter(uri=['xyz', 'abc'])
# Returns track with ID 1 and URI 'xyz' # Returns tracks with ID 1 and URI 'xyz'
filter({'id': 1, 'uri': 'xyz'}) filter({'id': [1], 'uri': ['xyz']})
filter(id=1, uri='xyz') filter(id=[1], uri=['xyz'])
# Returns track with a matching ID (1, 3 or 6) and a matching URI
# ('xyz' or 'abc')
filter({'id': [1, 3, 6], 'uri': ['xyz', 'abc']})
filter(id=[1, 3, 6], uri=['xyz', 'abc'])
:param criteria: on or more criteria to match by :param criteria: on or more criteria to match by
:type criteria: dict :type criteria: dict, of (string, list) pairs
:rtype: list of :class:`mopidy.models.TlTrack` :rtype: list of :class:`mopidy.models.TlTrack`
""" """
criteria = criteria or kwargs criteria = criteria or kwargs
matches = self._tl_tracks matches = self._tl_tracks
for (key, value) in criteria.iteritems(): for (key, values) in criteria.iteritems():
if (not isinstance(values, collections.Iterable)
or isinstance(values, basestring)):
# Fail hard if anyone is using the <0.17 calling style
raise ValueError('Filter values must be iterable: %r' % values)
if key == 'tlid': if key == 'tlid':
matches = filter(lambda ct: ct.tlid == value, matches) matches = filter(lambda ct: ct.tlid in values, matches)
else: else:
matches = filter( matches = filter(
lambda ct: getattr(ct.track, key) == value, matches) lambda ct: getattr(ct.track, key) in values, matches)
return matches return matches
def move(self, start, end, to_position): def move(self, start, end, to_position):
@ -435,7 +451,7 @@ class TracklistController(object):
"""Private method used by :class:`mopidy.core.PlaybackController`.""" """Private method used by :class:`mopidy.core.PlaybackController`."""
if not self.consume: if not self.consume:
return False return False
self.remove(tlid=tl_track.tlid) self.remove(tlid=[tl_track.tlid])
return True return True
def _trigger_tracklist_changed(self): def _trigger_tracklist_changed(self):

View File

@ -87,6 +87,14 @@ class Extension(object):
""" """
return [] return []
def get_command(self):
"""Command to expose to command line users running mopidy.
:returns:
Instance of a :class:`~mopidy.commands.Command` class.
"""
pass
def register_gstreamer_elements(self): def register_gstreamer_elements(self):
"""Hook for registering custom GStreamer elements """Hook for registering custom GStreamer elements

View File

@ -21,6 +21,7 @@ class Extension(ext.Extension):
schema['hostname'] = config.Hostname() schema['hostname'] = config.Hostname()
schema['port'] = config.Port() schema['port'] = config.Port()
schema['static_dir'] = config.Path(optional=True) schema['static_dir'] = config.Path(optional=True)
schema['zeroconf'] = config.String(optional=True)
return schema return schema
def validate_environment(self): def validate_environment(self):

View File

@ -11,6 +11,7 @@ from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool
from mopidy import models from mopidy import models
from mopidy.core import CoreListener from mopidy.core import CoreListener
from mopidy.utils import zeroconf
from . import ws from . import ws
@ -22,6 +23,12 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
super(HttpFrontend, self).__init__() super(HttpFrontend, self).__init__()
self.config = config self.config = config
self.core = core self.core = core
self.hostname = config['http']['hostname']
self.port = config['http']['port']
self.zeroconf_name = config['http']['zeroconf']
self.zeroconf_service = None
self._setup_server() self._setup_server()
self._setup_websocket_plugin() self._setup_websocket_plugin()
app = self._create_app() app = self._create_app()
@ -30,8 +37,8 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
def _setup_server(self): def _setup_server(self):
cherrypy.config.update({ cherrypy.config.update({
'engine.autoreload_on': False, 'engine.autoreload_on': False,
'server.socket_host': self.config['http']['hostname'], 'server.socket_host': self.hostname,
'server.socket_port': self.config['http']['port'], 'server.socket_port': self.port,
}) })
def _setup_websocket_plugin(self): def _setup_websocket_plugin(self):
@ -88,7 +95,21 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
cherrypy.engine.start() cherrypy.engine.start()
logger.info('HTTP server running at %s', cherrypy.server.base()) logger.info('HTTP server running at %s', cherrypy.server.base())
if self.zeroconf_name:
self.zeroconf_service = zeroconf.Zeroconf(
stype='_http._tcp', name=self.zeroconf_name,
host=self.hostname, port=self.port)
if self.zeroconf_service.publish():
logger.info('Registered HTTP with Zeroconf as "%s"',
self.zeroconf_service.name)
else:
logger.warning('Registering HTTP with Zeroconf failed.')
def on_stop(self): def on_stop(self):
if self.zeroconf_service:
self.zeroconf_service.unpublish()
logger.debug('Stopping HTTP server') logger.debug('Stopping HTTP server')
cherrypy.engine.exit() cherrypy.engine.exit()
logger.info('Stopped HTTP server') logger.info('Stopped HTTP server')

View File

@ -3,6 +3,7 @@ enabled = true
hostname = 127.0.0.1 hostname = 127.0.0.1
port = 6680 port = 6680
static_dir = static_dir =
zeroconf = Mopidy HTTP server on $hostname
[loglevels] [loglevels]
cherrypy = warning cherrypy = warning

View File

@ -23,6 +23,7 @@ class Extension(ext.Extension):
schema['password'] = config.Secret(optional=True) schema['password'] = config.Secret(optional=True)
schema['max_connections'] = config.Integer(minimum=1) schema['max_connections'] = config.Integer(minimum=1)
schema['connection_timeout'] = config.Integer(minimum=1) schema['connection_timeout'] = config.Integer(minimum=1)
schema['zeroconf'] = config.String(optional=True)
return schema return schema
def validate_environment(self): def validate_environment(self):

View File

@ -7,7 +7,7 @@ import pykka
from mopidy.core import CoreListener from mopidy.core import CoreListener
from mopidy.frontends.mpd import session from mopidy.frontends.mpd import session
from mopidy.utils import encoding, network, process from mopidy.utils import encoding, network, process, zeroconf
logger = logging.getLogger('mopidy.frontends.mpd') logger = logging.getLogger('mopidy.frontends.mpd')
@ -15,12 +15,16 @@ logger = logging.getLogger('mopidy.frontends.mpd')
class MpdFrontend(pykka.ThreadingActor, CoreListener): class MpdFrontend(pykka.ThreadingActor, CoreListener):
def __init__(self, config, core): def __init__(self, config, core):
super(MpdFrontend, self).__init__() super(MpdFrontend, self).__init__()
hostname = network.format_hostname(config['mpd']['hostname']) hostname = network.format_hostname(config['mpd']['hostname'])
port = config['mpd']['port'] self.hostname = hostname
self.port = config['mpd']['port']
self.zeroconf_name = config['mpd']['zeroconf']
self.zeroconf_service = None
try: try:
network.Server( network.Server(
hostname, port, self.hostname, self.port,
protocol=session.MpdSession, protocol=session.MpdSession,
protocol_kwargs={ protocol_kwargs={
'config': config, 'config': config,
@ -34,9 +38,24 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
encoding.locale_decode(error)) encoding.locale_decode(error))
sys.exit(1) sys.exit(1)
logger.info('MPD server running at [%s]:%s', hostname, port) logger.info('MPD server running at [%s]:%s', self.hostname, self.port)
def on_start(self):
if self.zeroconf_name:
self.zeroconf_service = zeroconf.Zeroconf(
stype='_mpd._tcp', name=self.zeroconf_name,
host=self.hostname, port=self.port)
if self.zeroconf_service.publish():
logger.info('Registered MPD with Zeroconf as "%s"',
self.zeroconf_service.name)
else:
logger.warning('Registering MPD with Zeroconf failed.')
def on_stop(self): def on_stop(self):
if self.zeroconf_service:
self.zeroconf_service.unpublish()
process.stop_actors_by_class(session.MpdSession) process.stop_actors_by_class(session.MpdSession)
def send_idle(self, subsystem): def send_idle(self, subsystem):

View File

@ -5,3 +5,4 @@ port = 6600
password = password =
max_connections = 20 max_connections = 20
connection_timeout = 60 connection_timeout = 60
zeroconf = Mopidy MPD server on $hostname

View File

@ -44,10 +44,15 @@ def handle_request(pattern, auth_required=True):
For example, if the command is ``do that thing`` the ``what`` argument will For example, if the command is ``do that thing`` the ``what`` argument will
be ``this thing``:: be ``this thing``::
@handle_request('^do (?P<what>.+)$') @handle_request('do\ (?P<what>.+)$')
def do(what): def do(what):
... ...
Note that the patterns are compiled with the :attr:`re.VERBOSE` flag. Thus,
you must escape any space characters you want to match, but you're also
free to add non-escaped whitespace to format the pattern for easier
reading.
:param pattern: regexp pattern for matching commands :param pattern: regexp pattern for matching commands
:type pattern: string :type pattern: string
""" """
@ -56,7 +61,7 @@ def handle_request(pattern, auth_required=True):
if match is not None: if match is not None:
mpd_commands.add( mpd_commands.add(
MpdCommand(name=match.group(), auth_required=auth_required)) MpdCommand(name=match.group(), auth_required=auth_required))
compiled_pattern = re.compile(pattern, flags=re.UNICODE) compiled_pattern = re.compile(pattern, flags=(re.UNICODE | re.VERBOSE))
if compiled_pattern in request_handlers: if compiled_pattern in request_handlers:
raise ValueError('Tried to redefine handler for %s with %s' % ( raise ValueError('Tried to redefine handler for %s with %s' % (
pattern, func)) pattern, func))

View File

@ -4,7 +4,7 @@ from mopidy.frontends.mpd.exceptions import MpdNoExistError
from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.protocol import handle_request
@handle_request(r'^disableoutput "(?P<outputid>\d+)"$') @handle_request(r'disableoutput\ "(?P<outputid>\d+)"$')
def disableoutput(context, outputid): def disableoutput(context, outputid):
""" """
*musicpd.org, audio output section:* *musicpd.org, audio output section:*
@ -19,7 +19,7 @@ def disableoutput(context, outputid):
raise MpdNoExistError('No such audio output', command='disableoutput') raise MpdNoExistError('No such audio output', command='disableoutput')
@handle_request(r'^enableoutput "(?P<outputid>\d+)"$') @handle_request(r'enableoutput\ "(?P<outputid>\d+)"$')
def enableoutput(context, outputid): def enableoutput(context, outputid):
""" """
*musicpd.org, audio output section:* *musicpd.org, audio output section:*
@ -34,7 +34,7 @@ def enableoutput(context, outputid):
raise MpdNoExistError('No such audio output', command='enableoutput') raise MpdNoExistError('No such audio output', command='enableoutput')
@handle_request(r'^outputs$') @handle_request(r'outputs$')
def outputs(context): def outputs(context):
""" """
*musicpd.org, audio output section:* *musicpd.org, audio output section:*

View File

@ -4,7 +4,7 @@ from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.exceptions import MpdNotImplemented
@handle_request(r'^subscribe "(?P<channel>[A-Za-z0-9:._-]+)"$') @handle_request(r'subscribe\ "(?P<channel>[A-Za-z0-9:._-]+)"$')
def subscribe(context, channel): def subscribe(context, channel):
""" """
*musicpd.org, client to client section:* *musicpd.org, client to client section:*
@ -18,7 +18,7 @@ def subscribe(context, channel):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^unsubscribe "(?P<channel>[A-Za-z0-9:._-]+)"$') @handle_request(r'unsubscribe\ "(?P<channel>[A-Za-z0-9:._-]+)"$')
def unsubscribe(context, channel): def unsubscribe(context, channel):
""" """
*musicpd.org, client to client section:* *musicpd.org, client to client section:*
@ -30,7 +30,7 @@ def unsubscribe(context, channel):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^channels$') @handle_request(r'channels$')
def channels(context): def channels(context):
""" """
*musicpd.org, client to client section:* *musicpd.org, client to client section:*
@ -43,7 +43,7 @@ def channels(context):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^readmessages$') @handle_request(r'readmessages$')
def readmessages(context): def readmessages(context):
""" """
*musicpd.org, client to client section:* *musicpd.org, client to client section:*
@ -57,7 +57,7 @@ def readmessages(context):
@handle_request( @handle_request(
r'^sendmessage "(?P<channel>[A-Za-z0-9:._-]+)" "(?P<text>[^"]*)"$') r'sendmessage\ "(?P<channel>[A-Za-z0-9:._-]+)"\ "(?P<text>[^"]*)"$')
def sendmessage(context, channel, text): def sendmessage(context, channel, text):
""" """
*musicpd.org, client to client section:* *musicpd.org, client to client section:*

View File

@ -4,7 +4,7 @@ from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.exceptions import MpdUnknownCommand from mopidy.frontends.mpd.exceptions import MpdUnknownCommand
@handle_request(r'^command_list_begin$') @handle_request(r'command_list_begin$')
def command_list_begin(context): def command_list_begin(context):
""" """
*musicpd.org, command list section:* *musicpd.org, command list section:*
@ -26,7 +26,7 @@ def command_list_begin(context):
context.dispatcher.command_list = [] context.dispatcher.command_list = []
@handle_request(r'^command_list_end$') @handle_request(r'command_list_end$')
def command_list_end(context): def command_list_end(context):
"""See :meth:`command_list_begin()`.""" """See :meth:`command_list_begin()`."""
if not context.dispatcher.command_list_receiving: if not context.dispatcher.command_list_receiving:
@ -49,7 +49,7 @@ def command_list_end(context):
return command_list_response return command_list_response
@handle_request(r'^command_list_ok_begin$') @handle_request(r'command_list_ok_begin$')
def command_list_ok_begin(context): def command_list_ok_begin(context):
"""See :meth:`command_list_begin()`.""" """See :meth:`command_list_begin()`."""
context.dispatcher.command_list_receiving = True context.dispatcher.command_list_receiving = True

View File

@ -5,7 +5,7 @@ from mopidy.frontends.mpd.exceptions import (
MpdPasswordError, MpdPermissionError) MpdPasswordError, MpdPermissionError)
@handle_request(r'^close$', auth_required=False) @handle_request(r'close$', auth_required=False)
def close(context): def close(context):
""" """
*musicpd.org, connection section:* *musicpd.org, connection section:*
@ -17,7 +17,7 @@ def close(context):
context.session.close() context.session.close()
@handle_request(r'^kill$') @handle_request(r'kill$')
def kill(context): def kill(context):
""" """
*musicpd.org, connection section:* *musicpd.org, connection section:*
@ -29,7 +29,7 @@ def kill(context):
raise MpdPermissionError(command='kill') raise MpdPermissionError(command='kill')
@handle_request(r'^password "(?P<password>[^"]+)"$', auth_required=False) @handle_request(r'password\ "(?P<password>[^"]+)"$', auth_required=False)
def password_(context, password): def password_(context, password):
""" """
*musicpd.org, connection section:* *musicpd.org, connection section:*
@ -45,7 +45,7 @@ def password_(context, password):
raise MpdPasswordError('incorrect password', command='password') raise MpdPasswordError('incorrect password', command='password')
@handle_request(r'^ping$', auth_required=False) @handle_request(r'ping$', auth_required=False)
def ping(context): def ping(context):
""" """
*musicpd.org, connection section:* *musicpd.org, connection section:*

View File

@ -6,7 +6,7 @@ from mopidy.frontends.mpd.exceptions import (
from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.protocol import handle_request
@handle_request(r'^add "(?P<uri>[^"]*)"$') @handle_request(r'add\ "(?P<uri>[^"]*)"$')
def add(context, uri): def add(context, uri):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -27,7 +27,7 @@ def add(context, uri):
raise MpdNoExistError('directory or file not found', command='add') raise MpdNoExistError('directory or file not found', command='add')
@handle_request(r'^addid "(?P<uri>[^"]*)"( "(?P<songpos>\d+)")*$') @handle_request(r'addid\ "(?P<uri>[^"]*)"(\ "(?P<songpos>\d+)")*$')
def addid(context, uri, songpos=None): def addid(context, uri, songpos=None):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -58,7 +58,7 @@ def addid(context, uri, songpos=None):
return ('Id', tl_tracks[0].tlid) return ('Id', tl_tracks[0].tlid)
@handle_request(r'^delete "(?P<start>\d+):(?P<end>\d+)*"$') @handle_request(r'delete\ "(?P<start>\d+):(?P<end>\d+)*"$')
def delete_range(context, start, end=None): def delete_range(context, start, end=None):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -76,22 +76,22 @@ def delete_range(context, start, end=None):
if not tl_tracks: if not tl_tracks:
raise MpdArgError('Bad song index', command='delete') raise MpdArgError('Bad song index', command='delete')
for (tlid, _) in tl_tracks: for (tlid, _) in tl_tracks:
context.core.tracklist.remove(tlid=tlid) context.core.tracklist.remove(tlid=[tlid])
@handle_request(r'^delete "(?P<songpos>\d+)"$') @handle_request(r'delete\ "(?P<songpos>\d+)"$')
def delete_songpos(context, songpos): def delete_songpos(context, songpos):
"""See :meth:`delete_range`""" """See :meth:`delete_range`"""
try: try:
songpos = int(songpos) songpos = int(songpos)
(tlid, _) = context.core.tracklist.slice( (tlid, _) = context.core.tracklist.slice(
songpos, songpos + 1).get()[0] songpos, songpos + 1).get()[0]
context.core.tracklist.remove(tlid=tlid) context.core.tracklist.remove(tlid=[tlid])
except IndexError: except IndexError:
raise MpdArgError('Bad song index', command='delete') raise MpdArgError('Bad song index', command='delete')
@handle_request(r'^deleteid "(?P<tlid>\d+)"$') @handle_request(r'deleteid\ "(?P<tlid>\d+)"$')
def deleteid(context, tlid): def deleteid(context, tlid):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -101,12 +101,12 @@ def deleteid(context, tlid):
Deletes the song ``SONGID`` from the playlist Deletes the song ``SONGID`` from the playlist
""" """
tlid = int(tlid) tlid = int(tlid)
tl_tracks = context.core.tracklist.remove(tlid=tlid).get() tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get()
if not tl_tracks: if not tl_tracks:
raise MpdNoExistError('No such song', command='deleteid') raise MpdNoExistError('No such song', command='deleteid')
@handle_request(r'^clear$') @handle_request(r'clear$')
def clear(context): def clear(context):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -118,7 +118,7 @@ def clear(context):
context.core.tracklist.clear() context.core.tracklist.clear()
@handle_request(r'^move "(?P<start>\d+):(?P<end>\d+)*" "(?P<to>\d+)"$') @handle_request(r'move\ "(?P<start>\d+):(?P<end>\d+)*"\ "(?P<to>\d+)"$')
def move_range(context, start, to, end=None): def move_range(context, start, to, end=None):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -136,7 +136,7 @@ def move_range(context, start, to, end=None):
context.core.tracklist.move(start, end, to) context.core.tracklist.move(start, end, to)
@handle_request(r'^move "(?P<songpos>\d+)" "(?P<to>\d+)"$') @handle_request(r'move\ "(?P<songpos>\d+)"\ "(?P<to>\d+)"$')
def move_songpos(context, songpos, to): def move_songpos(context, songpos, to):
"""See :meth:`move_range`.""" """See :meth:`move_range`."""
songpos = int(songpos) songpos = int(songpos)
@ -144,7 +144,7 @@ def move_songpos(context, songpos, to):
context.core.tracklist.move(songpos, songpos + 1, to) context.core.tracklist.move(songpos, songpos + 1, to)
@handle_request(r'^moveid "(?P<tlid>\d+)" "(?P<to>\d+)"$') @handle_request(r'moveid\ "(?P<tlid>\d+)"\ "(?P<to>\d+)"$')
def moveid(context, tlid, to): def moveid(context, tlid, to):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -157,14 +157,14 @@ def moveid(context, tlid, to):
""" """
tlid = int(tlid) tlid = int(tlid)
to = int(to) to = int(to)
tl_tracks = context.core.tracklist.filter(tlid=tlid).get() tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
if not tl_tracks: if not tl_tracks:
raise MpdNoExistError('No such song', command='moveid') raise MpdNoExistError('No such song', command='moveid')
position = context.core.tracklist.index(tl_tracks[0]).get() position = context.core.tracklist.index(tl_tracks[0]).get()
context.core.tracklist.move(position, position + 1, to) context.core.tracklist.move(position, position + 1, to)
@handle_request(r'^playlist$') @handle_request(r'playlist$')
def playlist(context): def playlist(context):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -180,8 +180,7 @@ def playlist(context):
return playlistinfo(context) return playlistinfo(context)
@handle_request(r'^playlistfind (?P<tag>[^"]+) "(?P<needle>[^"]+)"$') @handle_request(r'playlistfind\ ("?)(?P<tag>[^"]+)\1\ "(?P<needle>[^"]+)"$')
@handle_request(r'^playlistfind "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$')
def playlistfind(context, tag, needle): def playlistfind(context, tag, needle):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -195,7 +194,7 @@ def playlistfind(context, tag, needle):
- does not add quotes around the tag. - does not add quotes around the tag.
""" """
if tag == 'filename': if tag == 'filename':
tl_tracks = context.core.tracklist.filter(uri=needle).get() tl_tracks = context.core.tracklist.filter(uri=[needle]).get()
if not tl_tracks: if not tl_tracks:
return None return None
position = context.core.tracklist.index(tl_tracks[0]).get() position = context.core.tracklist.index(tl_tracks[0]).get()
@ -203,7 +202,8 @@ def playlistfind(context, tag, needle):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^playlistid( "(?P<tlid>\d+)")*$') @handle_request(r'playlistid$')
@handle_request(r'playlistid\ "(?P<tlid>\d+)"$')
def playlistid(context, tlid=None): def playlistid(context, tlid=None):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -215,7 +215,7 @@ def playlistid(context, tlid=None):
""" """
if tlid is not None: if tlid is not None:
tlid = int(tlid) tlid = int(tlid)
tl_tracks = context.core.tracklist.filter(tlid=tlid).get() tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
if not tl_tracks: if not tl_tracks:
raise MpdNoExistError('No such song', command='playlistid') raise MpdNoExistError('No such song', command='playlistid')
position = context.core.tracklist.index(tl_tracks[0]).get() position = context.core.tracklist.index(tl_tracks[0]).get()
@ -225,9 +225,9 @@ def playlistid(context, tlid=None):
context.core.tracklist.tl_tracks.get()) context.core.tracklist.tl_tracks.get())
@handle_request(r'^playlistinfo$') @handle_request(r'playlistinfo$')
@handle_request(r'^playlistinfo "(?P<songpos>-?\d+)"$') @handle_request(r'playlistinfo\ "(?P<songpos>-?\d+)"$')
@handle_request(r'^playlistinfo "(?P<start>\d+):(?P<end>\d+)*"$') @handle_request(r'playlistinfo\ "(?P<start>\d+):(?P<end>\d+)*"$')
def playlistinfo(context, songpos=None, start=None, end=None): def playlistinfo(context, songpos=None, start=None, end=None):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -263,8 +263,7 @@ def playlistinfo(context, songpos=None, start=None, end=None):
return translator.tracks_to_mpd_format(tl_tracks, start, end) return translator.tracks_to_mpd_format(tl_tracks, start, end)
@handle_request(r'^playlistsearch "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$') @handle_request(r'playlistsearch\ ("?)(?P<tag>\w+)\1\ "(?P<needle>[^"]+)"$')
@handle_request(r'^playlistsearch (?P<tag>\w+) "(?P<needle>[^"]+)"$')
def playlistsearch(context, tag, needle): def playlistsearch(context, tag, needle):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -282,8 +281,7 @@ def playlistsearch(context, tag, needle):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^plchanges (?P<version>-?\d+)$') @handle_request(r'plchanges\ ("?)(?P<version>-?\d+)\1$')
@handle_request(r'^plchanges "(?P<version>-?\d+)"$')
def plchanges(context, version): def plchanges(context, version):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -305,7 +303,7 @@ def plchanges(context, version):
context.core.tracklist.tl_tracks.get()) context.core.tracklist.tl_tracks.get())
@handle_request(r'^plchangesposid "(?P<version>\d+)"$') @handle_request(r'plchangesposid\ "(?P<version>\d+)"$')
def plchangesposid(context, version): def plchangesposid(context, version):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -329,8 +327,8 @@ def plchangesposid(context, version):
return result return result
@handle_request(r'^shuffle$') @handle_request(r'shuffle$')
@handle_request(r'^shuffle "(?P<start>\d+):(?P<end>\d+)*"$') @handle_request(r'shuffle\ "(?P<start>\d+):(?P<end>\d+)*"$')
def shuffle(context, start=None, end=None): def shuffle(context, start=None, end=None):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -347,7 +345,7 @@ def shuffle(context, start=None, end=None):
context.core.tracklist.shuffle(start, end) context.core.tracklist.shuffle(start, end)
@handle_request(r'^swap "(?P<songpos1>\d+)" "(?P<songpos2>\d+)"$') @handle_request(r'swap\ "(?P<songpos1>\d+)"\ "(?P<songpos2>\d+)"$')
def swap(context, songpos1, songpos2): def swap(context, songpos1, songpos2):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -369,7 +367,7 @@ def swap(context, songpos1, songpos2):
context.core.tracklist.add(tracks) context.core.tracklist.add(tracks)
@handle_request(r'^swapid "(?P<tlid1>\d+)" "(?P<tlid2>\d+)"$') @handle_request(r'swapid\ "(?P<tlid1>\d+)"\ "(?P<tlid2>\d+)"$')
def swapid(context, tlid1, tlid2): def swapid(context, tlid1, tlid2):
""" """
*musicpd.org, current playlist section:* *musicpd.org, current playlist section:*
@ -380,8 +378,8 @@ def swapid(context, tlid1, tlid2):
""" """
tlid1 = int(tlid1) tlid1 = int(tlid1)
tlid2 = int(tlid2) tlid2 = int(tlid2)
tl_tracks1 = context.core.tracklist.filter(tlid=tlid1).get() tl_tracks1 = context.core.tracklist.filter(tlid=[tlid1]).get()
tl_tracks2 = context.core.tracklist.filter(tlid=tlid2).get() tl_tracks2 = context.core.tracklist.filter(tlid=[tlid2]).get()
if not tl_tracks1 or not tl_tracks2: if not tl_tracks1 or not tl_tracks2:
raise MpdNoExistError('No such song', command='swapid') raise MpdNoExistError('No such song', command='swapid')
position1 = context.core.tracklist.index(tl_tracks1[0]).get() position1 = context.core.tracklist.index(tl_tracks1[0]).get()

View File

@ -3,7 +3,7 @@ from __future__ import unicode_literals
from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.protocol import handle_request
@handle_request(r'^[ ]*$') @handle_request(r'[\ ]*$')
def empty(context): def empty(context):
"""The original MPD server returns ``OK`` on an empty request.""" """The original MPD server returns ``OK`` on an empty request."""
pass pass

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
import functools import functools
import itertools import itertools
import re
from mopidy.models import Track from mopidy.models import Track
from mopidy.frontends.mpd import translator from mopidy.frontends.mpd import translator
@ -9,9 +10,114 @@ from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented
from mopidy.frontends.mpd.protocol import handle_request, stored_playlists from mopidy.frontends.mpd.protocol import handle_request, stored_playlists
QUERY_RE = ( LIST_QUERY = r"""
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Aa]lbumartist|[Dd]ate|[Ff]ile|' ("?) # Optional quote around the field type
r'[Ff]ilename|[Tt]itle|[Tt]rack|[Aa]ny)"? "[^"]*"\s?)+)$') (?P<field>( # Field to list in the response
[Aa]lbum
| [Aa]lbumartist
| [Aa]rtist
| [Cc]omposer
| [Dd]ate
| [Gg]enre
| [Pp]erformer
))
\1 # End of optional quote around the field type
(?: # Non-capturing group for optional search query
\ # A single space
(?P<mpd_query>.*)
)?
$
"""
SEARCH_FIELDS = r"""
[Aa]lbum
| [Aa]lbumartist
| [Aa]ny
| [Aa]rtist
| [Cc]omment
| [Cc]omposer
| [Dd]ate
| [Ff]ile
| [Ff]ilename
| [Gg]enre
| [Pp]erformer
| [Tt]itle
| [Tt]rack
"""
# TODO Would be nice to get ("?)...\1 working for the quotes here
SEARCH_QUERY = r"""
(?P<mpd_query>
(?: # Non-capturing group for repeating query pairs
"? # Optional quote around the field type
(?:
""" + SEARCH_FIELDS + r"""
)
"? # End of optional quote around the field type
\ # A single space
"[^"]*" # Matching a quoted search string
\s?
)+
)
$
"""
# TODO Would be nice to get ("?)...\1 working for the quotes here
SEARCH_PAIR_WITHOUT_GROUPS = r"""
\b # Only begin matching at word bundaries
"? # Optional quote around the field type
(?: # A non-capturing group for the field type
""" + SEARCH_FIELDS + """
)
"? # End of optional quote around the field type
\ # A single space
"[^"]+" # Matching a quoted search string
"""
SEARCH_PAIR_WITHOUT_GROUPS_RE = re.compile(
SEARCH_PAIR_WITHOUT_GROUPS, flags=(re.UNICODE | re.VERBOSE))
# TODO Would be nice to get ("?)...\1 working for the quotes here
SEARCH_PAIR_WITH_GROUPS = r"""
\b # Only begin matching at word bundaries
"? # Optional quote around the field type
(?P<field>( # A capturing group for the field type
""" + SEARCH_FIELDS + """
))
"? # End of optional quote around the field type
\ # A single space
"(?P<what>[^"]+)" # Capturing a quoted search string
"""
SEARCH_PAIR_WITH_GROUPS_RE = re.compile(
SEARCH_PAIR_WITH_GROUPS, flags=(re.UNICODE | re.VERBOSE))
def _query_from_mpd_search_format(mpd_query):
"""
Parses an MPD ``search`` or ``find`` query and converts it to the Mopidy
query format.
:param mpd_query: the MPD search query
:type mpd_query: string
"""
pairs = SEARCH_PAIR_WITHOUT_GROUPS_RE.findall(mpd_query)
query = {}
for pair in pairs:
m = SEARCH_PAIR_WITH_GROUPS_RE.match(pair)
field = m.groupdict()['field'].lower()
if field == 'title':
field = 'track_name'
elif field == 'track':
field = 'track_no'
elif field in ('file', 'filename'):
field = 'uri'
what = m.groupdict()['what']
if not what:
raise ValueError
if field in query:
query[field].append(what)
else:
query[field] = [what]
return query
def _get_field(field, search_results): def _get_field(field, search_results):
@ -39,7 +145,7 @@ def _artist_as_track(artist):
artists=[artist]) artists=[artist])
@handle_request(r'^count ' + QUERY_RE) @handle_request(r'count\ ' + SEARCH_QUERY)
def count(context, mpd_query): def count(context, mpd_query):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -55,7 +161,7 @@ def count(context, mpd_query):
- use multiple tag-needle pairs to make more specific searches. - use multiple tag-needle pairs to make more specific searches.
""" """
try: try:
query = translator.query_from_mpd_search_format(mpd_query) query = _query_from_mpd_search_format(mpd_query)
except ValueError: except ValueError:
raise MpdArgError('incorrect arguments', command='count') raise MpdArgError('incorrect arguments', command='count')
results = context.core.library.find_exact(**query).get() results = context.core.library.find_exact(**query).get()
@ -66,7 +172,7 @@ def count(context, mpd_query):
] ]
@handle_request(r'^find ' + QUERY_RE) @handle_request(r'find\ ' + SEARCH_QUERY)
def find(context, mpd_query): def find(context, mpd_query):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -95,12 +201,15 @@ def find(context, mpd_query):
- uses "file" instead of "filename". - uses "file" instead of "filename".
""" """
try: try:
query = translator.query_from_mpd_search_format(mpd_query) query = _query_from_mpd_search_format(mpd_query)
except ValueError: except ValueError:
return return
results = context.core.library.find_exact(**query).get() results = context.core.library.find_exact(**query).get()
result_tracks = [] result_tracks = []
if 'artist' not in query and 'albumartist' not in query: if ('artist' not in query and
'albumartist' not in query and
'composer' not in query and
'performer' not in query):
result_tracks += [_artist_as_track(a) for a in _get_artists(results)] result_tracks += [_artist_as_track(a) for a in _get_artists(results)]
if 'album' not in query: if 'album' not in query:
result_tracks += [_album_as_track(a) for a in _get_albums(results)] result_tracks += [_album_as_track(a) for a in _get_albums(results)]
@ -108,7 +217,7 @@ def find(context, mpd_query):
return translator.tracks_to_mpd_format(result_tracks) return translator.tracks_to_mpd_format(result_tracks)
@handle_request(r'^findadd ' + QUERY_RE) @handle_request(r'findadd\ ' + SEARCH_QUERY)
def findadd(context, mpd_query): def findadd(context, mpd_query):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -119,16 +228,14 @@ def findadd(context, mpd_query):
current playlist. Parameters have the same meaning as for ``find``. current playlist. Parameters have the same meaning as for ``find``.
""" """
try: try:
query = translator.query_from_mpd_search_format(mpd_query) query = _query_from_mpd_search_format(mpd_query)
except ValueError: except ValueError:
return return
results = context.core.library.find_exact(**query).get() results = context.core.library.find_exact(**query).get()
context.core.tracklist.add(_get_tracks(results)) context.core.tracklist.add(_get_tracks(results))
@handle_request( @handle_request(r'list\ ' + LIST_QUERY)
r'^list "?(?P<field>([Aa]rtist|[Aa]lbumartist|[Aa]lbum|[Dd]ate|'
r'[Gg]enre))"?( (?P<mpd_query>.*))?$')
def list_(context, field, mpd_query=None): def list_(context, field, mpd_query=None):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -222,10 +329,14 @@ def list_(context, field, mpd_query=None):
return _list_albumartist(context, query) return _list_albumartist(context, query)
elif field == 'album': elif field == 'album':
return _list_album(context, query) return _list_album(context, query)
elif field == 'composer':
return _list_composer(context, query)
elif field == 'performer':
return _list_performer(context, query)
elif field == 'date': elif field == 'date':
return _list_date(context, query) return _list_date(context, query)
elif field == 'genre': elif field == 'genre':
pass # TODO We don't have genre in our internal data structures yet return _list_genre(context, query)
def _list_artist(context, query): def _list_artist(context, query):
@ -258,6 +369,26 @@ def _list_album(context, query):
return albums return albums
def _list_composer(context, query):
composers = set()
results = context.core.library.find_exact(**query).get()
for track in _get_tracks(results):
for composer in track.composers:
if composer.name:
composers.add(('Composer', composer.name))
return composers
def _list_performer(context, query):
performers = set()
results = context.core.library.find_exact(**query).get()
for track in _get_tracks(results):
for performer in track.performers:
if performer.name:
performers.add(('Performer', performer.name))
return performers
def _list_date(context, query): def _list_date(context, query):
dates = set() dates = set()
results = context.core.library.find_exact(**query).get() results = context.core.library.find_exact(**query).get()
@ -267,8 +398,17 @@ def _list_date(context, query):
return dates return dates
@handle_request(r'^listall$') def _list_genre(context, query):
@handle_request(r'^listall "(?P<uri>[^"]+)"$') genres = set()
results = context.core.library.find_exact(**query).get()
for track in _get_tracks(results):
if track.genre:
genres.add(('Genre', track.genre))
return genres
@handle_request(r'listall$')
@handle_request(r'listall\ "(?P<uri>[^"]+)"$')
def listall(context, uri=None): def listall(context, uri=None):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -280,8 +420,8 @@ def listall(context, uri=None):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^listallinfo$') @handle_request(r'listallinfo$')
@handle_request(r'^listallinfo "(?P<uri>[^"]+)"$') @handle_request(r'listallinfo\ "(?P<uri>[^"]+)"$')
def listallinfo(context, uri=None): def listallinfo(context, uri=None):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -294,8 +434,8 @@ def listallinfo(context, uri=None):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^lsinfo$') @handle_request(r'lsinfo$')
@handle_request(r'^lsinfo "(?P<uri>[^"]*)"$') @handle_request(r'lsinfo\ "(?P<uri>[^"]*)"$')
def lsinfo(context, uri=None): def lsinfo(context, uri=None):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -317,7 +457,8 @@ def lsinfo(context, uri=None):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^rescan( "(?P<uri>[^"]+)")*$') @handle_request(r'rescan$')
@handle_request(r'rescan\ "(?P<uri>[^"]+)"$')
def rescan(context, uri=None): def rescan(context, uri=None):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -329,7 +470,7 @@ def rescan(context, uri=None):
return update(context, uri, rescan_unmodified_files=True) return update(context, uri, rescan_unmodified_files=True)
@handle_request(r'^search ' + QUERY_RE) @handle_request(r'search\ ' + SEARCH_QUERY)
def search(context, mpd_query): def search(context, mpd_query):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -358,7 +499,7 @@ def search(context, mpd_query):
- uses "file" instead of "filename". - uses "file" instead of "filename".
""" """
try: try:
query = translator.query_from_mpd_search_format(mpd_query) query = _query_from_mpd_search_format(mpd_query)
except ValueError: except ValueError:
return return
results = context.core.library.search(**query).get() results = context.core.library.search(**query).get()
@ -368,7 +509,7 @@ def search(context, mpd_query):
return translator.tracks_to_mpd_format(artists + albums + tracks) return translator.tracks_to_mpd_format(artists + albums + tracks)
@handle_request(r'^searchadd ' + QUERY_RE) @handle_request(r'searchadd\ ' + SEARCH_QUERY)
def searchadd(context, mpd_query): def searchadd(context, mpd_query):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -382,14 +523,14 @@ def searchadd(context, mpd_query):
not case sensitive. not case sensitive.
""" """
try: try:
query = translator.query_from_mpd_search_format(mpd_query) query = _query_from_mpd_search_format(mpd_query)
except ValueError: except ValueError:
return return
results = context.core.library.search(**query).get() results = context.core.library.search(**query).get()
context.core.tracklist.add(_get_tracks(results)) context.core.tracklist.add(_get_tracks(results))
@handle_request(r'^searchaddpl "(?P<playlist_name>[^"]+)" ' + QUERY_RE) @handle_request(r'searchaddpl\ "(?P<playlist_name>[^"]+)"\ ' + SEARCH_QUERY)
def searchaddpl(context, playlist_name, mpd_query): def searchaddpl(context, playlist_name, mpd_query):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*
@ -405,7 +546,7 @@ def searchaddpl(context, playlist_name, mpd_query):
not case sensitive. not case sensitive.
""" """
try: try:
query = translator.query_from_mpd_search_format(mpd_query) query = _query_from_mpd_search_format(mpd_query)
except ValueError: except ValueError:
return return
results = context.core.library.search(**query).get() results = context.core.library.search(**query).get()
@ -418,7 +559,8 @@ def searchaddpl(context, playlist_name, mpd_query):
context.core.playlists.save(playlist) context.core.playlists.save(playlist)
@handle_request(r'^update( "(?P<uri>[^"]+)")*$') @handle_request(r'update$')
@handle_request(r'update\ "(?P<uri>[^"]+)"$')
def update(context, uri=None, rescan_unmodified_files=False): def update(context, uri=None, rescan_unmodified_files=False):
""" """
*musicpd.org, music database section:* *musicpd.org, music database section:*

View File

@ -6,8 +6,7 @@ from mopidy.frontends.mpd.exceptions import (
MpdArgError, MpdNoExistError, MpdNotImplemented) MpdArgError, MpdNoExistError, MpdNotImplemented)
@handle_request(r'^consume (?P<state>[01])$') @handle_request(r'consume\ ("?)(?P<state>[01])\1$')
@handle_request(r'^consume "(?P<state>[01])"$')
def consume(context, state): def consume(context, state):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -24,7 +23,7 @@ def consume(context, state):
context.core.tracklist.consume = False context.core.tracklist.consume = False
@handle_request(r'^crossfade "(?P<seconds>\d+)"$') @handle_request(r'crossfade\ "(?P<seconds>\d+)"$')
def crossfade(context, seconds): def crossfade(context, seconds):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -37,7 +36,7 @@ def crossfade(context, seconds):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^next$') @handle_request(r'next$')
def next_(context): def next_(context):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -95,8 +94,8 @@ def next_(context):
return context.core.playback.next().get() return context.core.playback.next().get()
@handle_request(r'^pause$') @handle_request(r'pause$')
@handle_request(r'^pause "(?P<state>[01])"$') @handle_request(r'pause\ "(?P<state>[01])"$')
def pause(context, state=None): def pause(context, state=None):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -120,7 +119,7 @@ def pause(context, state=None):
context.core.playback.resume() context.core.playback.resume()
@handle_request(r'^play$') @handle_request(r'play$')
def play(context): def play(context):
""" """
The original MPD server resumes from the paused state on ``play`` The original MPD server resumes from the paused state on ``play``
@ -129,8 +128,7 @@ def play(context):
return context.core.playback.play().get() return context.core.playback.play().get()
@handle_request(r'^playid (?P<tlid>-?\d+)$') @handle_request(r'playid\ ("?)(?P<tlid>-?\d+)\1$')
@handle_request(r'^playid "(?P<tlid>-?\d+)"$')
def playid(context, tlid): def playid(context, tlid):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -151,14 +149,13 @@ def playid(context, tlid):
tlid = int(tlid) tlid = int(tlid)
if tlid == -1: if tlid == -1:
return _play_minus_one(context) return _play_minus_one(context)
tl_tracks = context.core.tracklist.filter(tlid=tlid).get() tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
if not tl_tracks: if not tl_tracks:
raise MpdNoExistError('No such song', command='playid') raise MpdNoExistError('No such song', command='playid')
return context.core.playback.play(tl_tracks[0]).get() return context.core.playback.play(tl_tracks[0]).get()
@handle_request(r'^play (?P<songpos>-?\d+)$') @handle_request(r'play\ ("?)(?P<songpos>-?\d+)\1$')
@handle_request(r'^play "(?P<songpos>-?\d+)"$')
def playpos(context, songpos): def playpos(context, songpos):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -205,7 +202,7 @@ def _play_minus_one(context):
return # Fail silently return # Fail silently
@handle_request(r'^previous$') @handle_request(r'previous$')
def previous(context): def previous(context):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -252,8 +249,7 @@ def previous(context):
return context.core.playback.previous().get() return context.core.playback.previous().get()
@handle_request(r'^random (?P<state>[01])$') @handle_request(r'random\ ("?)(?P<state>[01])\1$')
@handle_request(r'^random "(?P<state>[01])"$')
def random(context, state): def random(context, state):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -268,8 +264,7 @@ def random(context, state):
context.core.tracklist.random = False context.core.tracklist.random = False
@handle_request(r'^repeat (?P<state>[01])$') @handle_request(r'repeat\ ("?)(?P<state>[01])\1$')
@handle_request(r'^repeat "(?P<state>[01])"$')
def repeat(context, state): def repeat(context, state):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -284,7 +279,7 @@ def repeat(context, state):
context.core.tracklist.repeat = False context.core.tracklist.repeat = False
@handle_request(r'^replay_gain_mode "(?P<mode>(off|track|album))"$') @handle_request(r'replay_gain_mode\ "(?P<mode>(off|track|album))"$')
def replay_gain_mode(context, mode): def replay_gain_mode(context, mode):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -301,7 +296,7 @@ def replay_gain_mode(context, mode):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^replay_gain_status$') @handle_request(r'replay_gain_status$')
def replay_gain_status(context): def replay_gain_status(context):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -314,8 +309,7 @@ def replay_gain_status(context):
return 'off' # TODO return 'off' # TODO
@handle_request(r'^seek (?P<songpos>\d+) (?P<seconds>\d+)$') @handle_request(r'seek\ ("?)(?P<songpos>\d+)\1\ ("?)(?P<seconds>\d+)\3$')
@handle_request(r'^seek "(?P<songpos>\d+)" "(?P<seconds>\d+)"$')
def seek(context, songpos, seconds): def seek(context, songpos, seconds):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -335,7 +329,7 @@ def seek(context, songpos, seconds):
context.core.playback.seek(int(seconds) * 1000).get() context.core.playback.seek(int(seconds) * 1000).get()
@handle_request(r'^seekid "(?P<tlid>\d+)" "(?P<seconds>\d+)"$') @handle_request(r'seekid\ "(?P<tlid>\d+)"\ "(?P<seconds>\d+)"$')
def seekid(context, tlid, seconds): def seekid(context, tlid, seconds):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -350,8 +344,8 @@ def seekid(context, tlid, seconds):
context.core.playback.seek(int(seconds) * 1000).get() context.core.playback.seek(int(seconds) * 1000).get()
@handle_request(r'^seekcur "(?P<position>\d+)"$') @handle_request(r'seekcur\ "(?P<position>\d+)"$')
@handle_request(r'^seekcur "(?P<diff>[-+]\d+)"$') @handle_request(r'seekcur\ "(?P<diff>[-+]\d+)"$')
def seekcur(context, position=None, diff=None): def seekcur(context, position=None, diff=None):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -370,8 +364,7 @@ def seekcur(context, position=None, diff=None):
context.core.playback.seek(position).get() context.core.playback.seek(position).get()
@handle_request(r'^setvol (?P<volume>[-+]*\d+)$') @handle_request(r'setvol\ ("?)(?P<volume>[-+]*\d+)\1$')
@handle_request(r'^setvol "(?P<volume>[-+]*\d+)"$')
def setvol(context, volume): def setvol(context, volume):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -392,8 +385,7 @@ def setvol(context, volume):
context.core.playback.volume = volume context.core.playback.volume = volume
@handle_request(r'^single (?P<state>[01])$') @handle_request(r'single\ ("?)(?P<state>[01])\1$')
@handle_request(r'^single "(?P<state>[01])"$')
def single(context, state): def single(context, state):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*
@ -410,7 +402,7 @@ def single(context, state):
context.core.tracklist.single = False context.core.tracklist.single = False
@handle_request(r'^stop$') @handle_request(r'stop$')
def stop(context): def stop(context):
""" """
*musicpd.org, playback section:* *musicpd.org, playback section:*

View File

@ -4,7 +4,7 @@ from mopidy.frontends.mpd.exceptions import MpdPermissionError
from mopidy.frontends.mpd.protocol import handle_request, mpd_commands from mopidy.frontends.mpd.protocol import handle_request, mpd_commands
@handle_request(r'^config$', auth_required=False) @handle_request(r'config$', auth_required=False)
def config(context): def config(context):
""" """
*musicpd.org, reflection section:* *musicpd.org, reflection section:*
@ -18,7 +18,7 @@ def config(context):
raise MpdPermissionError(command='config') raise MpdPermissionError(command='config')
@handle_request(r'^commands$', auth_required=False) @handle_request(r'commands$', auth_required=False)
def commands(context): def commands(context):
""" """
*musicpd.org, reflection section:* *musicpd.org, reflection section:*
@ -45,7 +45,7 @@ def commands(context):
('command', command_name) for command_name in sorted(command_names)] ('command', command_name) for command_name in sorted(command_names)]
@handle_request(r'^decoders$') @handle_request(r'decoders$')
def decoders(context): def decoders(context):
""" """
*musicpd.org, reflection section:* *musicpd.org, reflection section:*
@ -72,7 +72,7 @@ def decoders(context):
return # TODO return # TODO
@handle_request(r'^notcommands$', auth_required=False) @handle_request(r'notcommands$', auth_required=False)
def notcommands(context): def notcommands(context):
""" """
*musicpd.org, reflection section:* *musicpd.org, reflection section:*
@ -95,7 +95,7 @@ def notcommands(context):
('command', command_name) for command_name in sorted(command_names)] ('command', command_name) for command_name in sorted(command_names)]
@handle_request(r'^tagtypes$') @handle_request(r'tagtypes$')
def tagtypes(context): def tagtypes(context):
""" """
*musicpd.org, reflection section:* *musicpd.org, reflection section:*
@ -107,7 +107,7 @@ def tagtypes(context):
pass # TODO pass # TODO
@handle_request(r'^urlhandlers$') @handle_request(r'urlhandlers$')
def urlhandlers(context): def urlhandlers(context):
""" """
*musicpd.org, reflection section:* *musicpd.org, reflection section:*

View File

@ -13,7 +13,7 @@ SUBSYSTEMS = [
'stored_playlist', 'update'] 'stored_playlist', 'update']
@handle_request(r'^clearerror$') @handle_request(r'clearerror$')
def clearerror(context): def clearerror(context):
""" """
*musicpd.org, status section:* *musicpd.org, status section:*
@ -26,7 +26,7 @@ def clearerror(context):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^currentsong$') @handle_request(r'currentsong$')
def currentsong(context): def currentsong(context):
""" """
*musicpd.org, status section:* *musicpd.org, status section:*
@ -42,8 +42,8 @@ def currentsong(context):
return track_to_mpd_format(tl_track, position=position) return track_to_mpd_format(tl_track, position=position)
@handle_request(r'^idle$') @handle_request(r'idle$')
@handle_request(r'^idle (?P<subsystems>.+)$') @handle_request(r'idle\ (?P<subsystems>.+)$')
def idle(context, subsystems=None): def idle(context, subsystems=None):
""" """
*musicpd.org, status section:* *musicpd.org, status section:*
@ -100,7 +100,7 @@ def idle(context, subsystems=None):
return response return response
@handle_request(r'^noidle$') @handle_request(r'noidle$')
def noidle(context): def noidle(context):
"""See :meth:`_status_idle`.""" """See :meth:`_status_idle`."""
if not context.subscriptions: if not context.subscriptions:
@ -110,7 +110,7 @@ def noidle(context):
context.session.prevent_timeout = False context.session.prevent_timeout = False
@handle_request(r'^stats$') @handle_request(r'stats$')
def stats(context): def stats(context):
""" """
*musicpd.org, status section:* *musicpd.org, status section:*
@ -137,7 +137,7 @@ def stats(context):
} }
@handle_request(r'^status$') @handle_request(r'status$')
def status(context): def status(context):
""" """
*musicpd.org, status section:* *musicpd.org, status section:*
@ -214,8 +214,11 @@ def status(context):
def _status_bitrate(futures): def _status_bitrate(futures):
current_tl_track = futures['playback.current_tl_track'].get() current_tl_track = futures['playback.current_tl_track'].get()
if current_tl_track is not None: if current_tl_track is None:
return current_tl_track.track.bitrate return 0
if current_tl_track.track.bitrate is None:
return 0
return current_tl_track.track.bitrate
def _status_consume(futures): def _status_consume(futures):

View File

@ -5,8 +5,8 @@ from mopidy.frontends.mpd.exceptions import MpdNotImplemented
@handle_request( @handle_request(
r'^sticker delete "(?P<field>[^"]+)" ' r'sticker\ delete\ "(?P<field>[^"]+)"\ '
r'"(?P<uri>[^"]+)"( "(?P<name>[^"]+)")*$') r'"(?P<uri>[^"]+)"(\ "(?P<name>[^"]+)")*$')
def sticker_delete(context, field, uri, name=None): def sticker_delete(context, field, uri, name=None):
""" """
*musicpd.org, sticker section:* *musicpd.org, sticker section:*
@ -20,7 +20,7 @@ def sticker_delete(context, field, uri, name=None):
@handle_request( @handle_request(
r'^sticker find "(?P<field>[^"]+)" "(?P<uri>[^"]+)" ' r'sticker\ find\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
r'"(?P<name>[^"]+)"$') r'"(?P<name>[^"]+)"$')
def sticker_find(context, field, uri, name): def sticker_find(context, field, uri, name):
""" """
@ -36,7 +36,7 @@ def sticker_find(context, field, uri, name):
@handle_request( @handle_request(
r'^sticker get "(?P<field>[^"]+)" "(?P<uri>[^"]+)" ' r'sticker\ get\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
r'"(?P<name>[^"]+)"$') r'"(?P<name>[^"]+)"$')
def sticker_get(context, field, uri, name): def sticker_get(context, field, uri, name):
""" """
@ -49,7 +49,7 @@ def sticker_get(context, field, uri, name):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^sticker list "(?P<field>[^"]+)" "(?P<uri>[^"]+)"$') @handle_request(r'sticker\ list\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"$')
def sticker_list(context, field, uri): def sticker_list(context, field, uri):
""" """
*musicpd.org, sticker section:* *musicpd.org, sticker section:*
@ -62,8 +62,8 @@ def sticker_list(context, field, uri):
@handle_request( @handle_request(
r'^sticker set "(?P<field>[^"]+)" "(?P<uri>[^"]+)" ' r'sticker\ set\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
r'"(?P<name>[^"]+)" "(?P<value>[^"]+)"$') r'"(?P<name>[^"]+)"\ "(?P<value>[^"]+)"$')
def sticker_set(context, field, uri, name, value): def sticker_set(context, field, uri, name, value):
""" """
*musicpd.org, sticker section:* *musicpd.org, sticker section:*

View File

@ -7,8 +7,7 @@ from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.translator import playlist_to_mpd_format from mopidy.frontends.mpd.translator import playlist_to_mpd_format
@handle_request(r'^listplaylist (?P<name>\w+)$') @handle_request(r'listplaylist\ ("?)(?P<name>[^"]+)\1$')
@handle_request(r'^listplaylist "(?P<name>[^"]+)"$')
def listplaylist(context, name): def listplaylist(context, name):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*
@ -29,8 +28,7 @@ def listplaylist(context, name):
return ['file: %s' % t.uri for t in playlist.tracks] return ['file: %s' % t.uri for t in playlist.tracks]
@handle_request(r'^listplaylistinfo (?P<name>\w+)$') @handle_request(r'listplaylistinfo\ ("?)(?P<name>[^"]+)\1$')
@handle_request(r'^listplaylistinfo "(?P<name>[^"]+)"$')
def listplaylistinfo(context, name): def listplaylistinfo(context, name):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*
@ -50,7 +48,7 @@ def listplaylistinfo(context, name):
return playlist_to_mpd_format(playlist) return playlist_to_mpd_format(playlist)
@handle_request(r'^listplaylists$') @handle_request(r'listplaylists$')
def listplaylists(context): def listplaylists(context):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*
@ -92,7 +90,8 @@ def listplaylists(context):
return result return result
@handle_request(r'^load "(?P<name>[^"]+)"( "(?P<start>\d+):(?P<end>\d+)*")*$') @handle_request(
r'load\ "(?P<name>[^"]+)"(\ "(?P<start>\d+):(?P<end>\d+)*")*$')
def load(context, name, start=None, end=None): def load(context, name, start=None, end=None):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*
@ -124,7 +123,7 @@ def load(context, name, start=None, end=None):
context.core.tracklist.add(playlist.tracks[start:end]) context.core.tracklist.add(playlist.tracks[start:end])
@handle_request(r'^playlistadd "(?P<name>[^"]+)" "(?P<uri>[^"]+)"$') @handle_request(r'playlistadd\ "(?P<name>[^"]+)"\ "(?P<uri>[^"]+)"$')
def playlistadd(context, name, uri): def playlistadd(context, name, uri):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*
@ -138,7 +137,7 @@ def playlistadd(context, name, uri):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^playlistclear "(?P<name>[^"]+)"$') @handle_request(r'playlistclear\ "(?P<name>[^"]+)"$')
def playlistclear(context, name): def playlistclear(context, name):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*
@ -150,7 +149,7 @@ def playlistclear(context, name):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^playlistdelete "(?P<name>[^"]+)" "(?P<songpos>\d+)"$') @handle_request(r'playlistdelete\ "(?P<name>[^"]+)"\ "(?P<songpos>\d+)"$')
def playlistdelete(context, name, songpos): def playlistdelete(context, name, songpos):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*
@ -163,8 +162,8 @@ def playlistdelete(context, name, songpos):
@handle_request( @handle_request(
r'^playlistmove "(?P<name>[^"]+)" ' r'playlistmove\ "(?P<name>[^"]+)"\ '
r'"(?P<from_pos>\d+)" "(?P<to_pos>\d+)"$') r'"(?P<from_pos>\d+)"\ "(?P<to_pos>\d+)"$')
def playlistmove(context, name, from_pos, to_pos): def playlistmove(context, name, from_pos, to_pos):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*
@ -183,7 +182,7 @@ def playlistmove(context, name, from_pos, to_pos):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^rename "(?P<old_name>[^"]+)" "(?P<new_name>[^"]+)"$') @handle_request(r'rename\ "(?P<old_name>[^"]+)"\ "(?P<new_name>[^"]+)"$')
def rename(context, old_name, new_name): def rename(context, old_name, new_name):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*
@ -195,7 +194,7 @@ def rename(context, old_name, new_name):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^rm "(?P<name>[^"]+)"$') @handle_request(r'rm\ "(?P<name>[^"]+)"$')
def rm(context, name): def rm(context, name):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*
@ -207,7 +206,7 @@ def rm(context, name):
raise MpdNotImplemented # TODO raise MpdNotImplemented # TODO
@handle_request(r'^save "(?P<name>[^"]+)"$') @handle_request(r'save\ "(?P<name>[^"]+)"$')
def save(context, name): def save(context, name):
""" """
*musicpd.org, stored playlists section:* *musicpd.org, stored playlists section:*

View File

@ -37,16 +37,16 @@ def track_to_mpd_format(track, position=None):
('Artist', artists_to_mpd_format(track.artists)), ('Artist', artists_to_mpd_format(track.artists)),
('Title', track.name or ''), ('Title', track.name or ''),
('Album', track.album and track.album.name or ''), ('Album', track.album and track.album.name or ''),
('Date', track.date or ''),
] ]
if track.date:
result.append(('Date', track.date))
if track.album is not None and track.album.num_tracks != 0: if track.album is not None and track.album.num_tracks != 0:
result.append(('Track', '%d/%d' % ( result.append(('Track', '%d/%d' % (
track.track_no, track.album.num_tracks))) track.track_no, track.album.num_tracks)))
else: else:
result.append(('Track', track.track_no)) result.append(('Track', track.track_no))
if track.album is not None and track.album.artists:
artists = artists_to_mpd_format(track.album.artists)
result.append(('AlbumArtist', artists))
if position is not None and tlid is not None: if position is not None and tlid is not None:
result.append(('Pos', position)) result.append(('Pos', position))
result.append(('Id', tlid)) result.append(('Id', tlid))
@ -55,6 +55,8 @@ def track_to_mpd_format(track, position=None):
# FIXME don't use first and best artist? # FIXME don't use first and best artist?
# FIXME don't duplicate following code? # FIXME don't duplicate following code?
if track.album is not None and track.album.artists: if track.album is not None and track.album.artists:
artists = artists_to_mpd_format(track.album.artists)
result.append(('AlbumArtist', artists))
artists = filter( artists = filter(
lambda a: a.musicbrainz_id is not None, track.album.artists) lambda a: a.musicbrainz_id is not None, track.album.artists)
if artists: if artists:
@ -64,14 +66,31 @@ def track_to_mpd_format(track, position=None):
artists = filter(lambda a: a.musicbrainz_id is not None, track.artists) artists = filter(lambda a: a.musicbrainz_id is not None, track.artists)
if artists: if artists:
result.append(('MUSICBRAINZ_ARTISTID', artists[0].musicbrainz_id)) result.append(('MUSICBRAINZ_ARTISTID', artists[0].musicbrainz_id))
if track.composers:
result.append(('Composer', artists_to_mpd_format(track.composers)))
if track.performers:
result.append(('Performer', artists_to_mpd_format(track.performers)))
if track.genre:
result.append(('Genre', track.genre))
if track.disc_no:
result.append(('Disc', track.disc_no))
if track.comment:
result.append(('Comment', track.comment))
if track.musicbrainz_id is not None: if track.musicbrainz_id is not None:
result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id)) result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id))
return result return result
MPD_KEY_ORDER = ''' MPD_KEY_ORDER = '''
key file Time Artist AlbumArtist Title Album Track Date MUSICBRAINZ_ALBUMID key file Time Artist Album AlbumArtist Title Track Genre Date Composer
MUSICBRAINZ_ALBUMARTISTID MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime Performer Comment Disc MUSICBRAINZ_ALBUMID MUSICBRAINZ_ALBUMARTISTID
MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime
'''.split() '''.split()
@ -166,7 +185,8 @@ def query_from_mpd_list_format(field, mpd_query):
key = tokens[0].lower() key = tokens[0].lower()
value = tokens[1] value = tokens[1]
tokens = tokens[2:] tokens = tokens[2:]
if key not in ('artist', 'album', 'albumartist', 'date', 'genre'): if key not in ('artist', 'album', 'albumartist', 'composer',
'date', 'genre', 'performer'):
raise MpdArgError('not able to parse args', command='list') raise MpdArgError('not able to parse args', command='list')
if not value: if not value:
raise ValueError raise ValueError
@ -179,77 +199,6 @@ def query_from_mpd_list_format(field, mpd_query):
raise MpdArgError('not able to parse args', command='list') raise MpdArgError('not able to parse args', command='list')
# XXX The regexps below should be refactored to reuse common patterns here
# and in mopidy.frontends.mpd.protocol.music_db.QUERY_RE.
MPD_SEARCH_QUERY_RE = re.compile(r"""
\b # Only begin matching at word bundaries
"? # Optional quote around the field type
(?: # A non-capturing group for the field type
[Aa]lbum
| [Aa]rtist
| [Aa]lbumartist
| [Dd]ate
| [Ff]ile
| [Ff]ilename
| [Tt]itle
| [Tt]rack
| [Aa]ny
)
"? # End of optional quote around the field type
\s # A single space
"[^"]+" # Matching a quoted search string
""", re.VERBOSE)
MPD_SEARCH_QUERY_PART_RE = re.compile(r"""
\b # Only begin matching at word bundaries
"? # Optional quote around the field type
(?P<field>( # A capturing group for the field type
[Aa]lbum
| [Aa]rtist
| [Aa]lbumartist
| [Dd]ate
| [Ff]ile
| [Ff]ilename
| [Tt]itle
| [Tt]rack
| [Aa]ny
))
"? # End of optional quote around the field type
\s # A single space
"(?P<what>[^"]+)" # Capturing a quoted search string
""", re.VERBOSE)
def query_from_mpd_search_format(mpd_query):
"""
Parses an MPD ``search`` or ``find`` query and converts it to the Mopidy
query format.
:param mpd_query: the MPD search query
:type mpd_query: string
"""
query_parts = MPD_SEARCH_QUERY_RE.findall(mpd_query)
query = {}
for query_part in query_parts:
m = MPD_SEARCH_QUERY_PART_RE.match(query_part)
field = m.groupdict()['field'].lower()
if field == 'title':
field = 'track'
elif field == 'track':
field = 'track_no'
elif field in ('file', 'filename'):
field = 'uri'
what = m.groupdict()['what']
if not what:
raise ValueError
if field in query:
query[field].append(what)
else:
query[field] = [what]
return query
# TODO: move to tagcache backend. # TODO: move to tagcache backend.
def tracks_to_tag_cache_format(tracks, media_dir): def tracks_to_tag_cache_format(tracks, media_dir):
""" """
@ -294,6 +243,12 @@ def _add_to_tag_cache(result, dirs, files, media_dir):
for track in files: for track in files:
track_result = dict(track_to_mpd_format(track)) track_result = dict(track_to_mpd_format(track))
# XXX Don't save comments to the tag cache as they may span multiple
# lines. We'll start saving track comments when we move from tag_cache
# to a JSON file. See #579 for details.
if 'Comment' in track_result:
del track_result['Comment']
path = uri_to_path(track_result['file']) path = uri_to_path(track_result['file'])
try: try:
text_path = path.decode('utf-8') text_path = path.decode('utf-8')
@ -302,6 +257,7 @@ def _add_to_tag_cache(result, dirs, files, media_dir):
relative_path = os.path.relpath(path, base_path) relative_path = os.path.relpath(path, base_path)
relative_uri = urllib.quote(relative_path) relative_uri = urllib.quote(relative_path)
# TODO: use track.last_modified
track_result['file'] = relative_uri track_result['file'] = relative_uri
track_result['mtime'] = get_mtime(path) track_result['mtime'] = get_mtime(path)
track_result['key'] = os.path.basename(text_path) track_result['key'] = os.path.basename(text_path)

View File

@ -219,6 +219,12 @@ class Track(ImmutableObject):
:type artists: list of :class:`Artist` :type artists: list of :class:`Artist`
:param album: track album :param album: track album
:type album: :class:`Album` :type album: :class:`Album`
:param composers: track composers
:type composers: string
:param performers: track performers
:type performers: string
:param genre: track genre
:type genre: string
:param track_no: track number in album :param track_no: track number in album
:type track_no: integer :type track_no: integer
:param disc_no: disc number in album :param disc_no: disc number in album
@ -229,6 +235,8 @@ class Track(ImmutableObject):
:type length: integer :type length: integer
:param bitrate: bitrate in kbit/s :param bitrate: bitrate in kbit/s
:type bitrate: integer :type bitrate: integer
:param comment: track comment
:type comment: string
:param musicbrainz_id: MusicBrainz ID :param musicbrainz_id: MusicBrainz ID
:type musicbrainz_id: string :type musicbrainz_id: string
:param last_modified: Represents last modification time :param last_modified: Represents last modification time
@ -247,6 +255,15 @@ class Track(ImmutableObject):
#: The track :class:`Album`. Read-only. #: The track :class:`Album`. Read-only.
album = None album = None
#: A set of track composers. Read-only.
composers = frozenset()
#: A set of track performers`. Read-only.
performers = frozenset()
#: The track genre. Read-only.
genre = None
#: The track number in the album. Read-only. #: The track number in the album. Read-only.
track_no = 0 track_no = 0
@ -262,6 +279,9 @@ class Track(ImmutableObject):
#: The track's bitrate in kbit/s. Read-only. #: The track's bitrate in kbit/s. Read-only.
bitrate = None bitrate = None
#: The track comment. Read-only.
comment = None
#: The MusicBrainz ID of the track. Read-only. #: The MusicBrainz ID of the track. Read-only.
musicbrainz_id = None musicbrainz_id = None
@ -272,6 +292,8 @@ class Track(ImmutableObject):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.__dict__['artists'] = frozenset(kwargs.pop('artists', [])) self.__dict__['artists'] = frozenset(kwargs.pop('artists', []))
self.__dict__['composers'] = frozenset(kwargs.pop('composers', []))
self.__dict__['performers'] = frozenset(kwargs.pop('performers', []))
super(Track, self).__init__(*args, **kwargs) super(Track, self).__init__(*args, **kwargs)

View File

@ -1,228 +0,0 @@
from __future__ import unicode_literals
import argparse
import datetime
import logging
import os
import sys
import gobject
gobject.threads_init()
# Extract any command line arguments. This needs to be done before GStreamer is
# imported, so that GStreamer doesn't hijack e.g. ``--help``.
mopidy_args = sys.argv[1:]
sys.argv[1:] = []
import pygst
pygst.require('0.10')
import gst
import gst.pbutils
from mopidy import config as config_lib, exceptions, ext
from mopidy.models import Track, Artist, Album
from mopidy.utils import log, path, versioning
def main():
args = parse_args()
# TODO: support config files and overrides (shared from main?)
config_files = [b'/etc/mopidy/mopidy.conf',
b'$XDG_CONFIG_DIR/mopidy/mopidy.conf']
config_overrides = []
# TODO: decide if we want to avoid this boilerplate some how.
# Initial config without extensions to bootstrap logging.
logging_config, _ = config_lib.load(config_files, [], config_overrides)
log.setup_root_logger()
log.setup_console_logging(logging_config, args.verbosity_level)
extensions = ext.load_extensions()
config, errors = config_lib.load(
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.
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']
excluded_extensions = config['local']['excluded_file_extensions']
uris_library = set()
uris_update = set()
uris_remove = set()
logging.info('Checking tracks from library.')
for track in local_updater.load():
try:
stat = os.stat(path.uri_to_path(track.uri))
if int(stat.st_mtime) > track.last_modified:
uris_update.add(track.uri)
uris_library.add(track.uri)
except OSError:
uris_remove.add(track.uri)
logging.info('Removing %d moved or deleted tracks.', len(uris_remove))
for uri in uris_remove:
local_updater.remove(uri)
logging.info('Checking %s for new or modified tracks.', media_dir)
for uri in path.find_uris(config['local']['media_dir']):
if os.path.splitext(path.uri_to_path(uri))[1] in excluded_extensions:
logging.debug('Skipped %s: File extension excluded.', uri)
continue
if uri not in uris_library:
uris_update.add(uri)
logging.info('Found %d new or modified tracks.', len(uris_update))
logging.info('Scanning new and modified tracks.')
scanner = Scanner(config['local']['scan_timeout'])
for uri in uris_update:
try:
data = scanner.scan(uri)
data[b'mtime'] = os.path.getmtime(path.uri_to_path(uri))
track = translator(data)
local_updater.add(track)
logging.debug('Added %s', track.uri)
except exceptions.ScannerError as error:
logging.warning('Failed %s: %s', uri, error)
logging.info('Done scanning; commiting changes.')
local_updater.commit()
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument(
'--version', action='version',
version='Mopidy %s' % versioning.get_version())
parser.add_argument(
'-q', '--quiet',
action='store_const', const=0, dest='verbosity_level',
help='less output (warning level)')
parser.add_argument(
'-v', '--verbose',
action='count', default=1, dest='verbosity_level',
help='more output (debug level)')
return parser.parse_args(args=mopidy_args)
# TODO: move into scanner.
def translator(data):
albumartist_kwargs = {}
album_kwargs = {}
artist_kwargs = {}
track_kwargs = {}
def _retrieve(source_key, target_key, target):
if source_key in data:
target[target_key] = data[source_key]
_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]:
date = data[gst.TAG_DATE]
try:
date = datetime.date(date.year, date.month, date.day)
except ValueError:
pass # Ignore invalid dates
else:
track_kwargs['date'] = date.isoformat()
_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)
_retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs)
_retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs)
_retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs)
_retrieve(
'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs)
if albumartist_kwargs:
album_kwargs['artists'] = [Artist(**albumartist_kwargs)]
track_kwargs['uri'] = data['uri']
track_kwargs['last_modified'] = int(data['mtime'])
track_kwargs['length'] = data[gst.TAG_DURATION]
track_kwargs['album'] = Album(**album_kwargs)
track_kwargs['artists'] = [Artist(**artist_kwargs)]
return Track(**track_kwargs)
class Scanner(object):
def __init__(self, timeout=1000):
self.discoverer = gst.pbutils.Discoverer(timeout * 1000000)
def scan(self, uri):
try:
info = self.discoverer.discover_uri(uri)
except gobject.GError as e:
# Loosing traceback is non-issue since this is from C code.
raise exceptions.ScannerError(e)
data = {}
audio_streams = info.get_audio_streams()
if not audio_streams:
raise exceptions.ScannerError('Did not find any audio streams.')
for stream in audio_streams:
taglist = stream.get_tags()
if not taglist:
continue
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]
else:
data[key] = taglist[key]
# Never trust metadata for these fields:
data[b'uri'] = uri
data[b'duration'] = info.get_duration() // gst.MSECOND
if data[b'duration'] < 100:
raise exceptions.ScannerError(
'Rejecting file with less than 100ms audio data.')
return data
if __name__ == '__main__':
main()

View File

@ -4,23 +4,46 @@ import logging
import logging.config import logging.config
import logging.handlers import logging.handlers
from . import versioning
class DelayedHandler(logging.Handler):
def __init__(self):
logging.Handler.__init__(self)
self._released = False
self._buffer = []
def handle(self, record):
if not self._released:
self._buffer.append(record)
def release(self):
self._released = True
root = logging.getLogger('')
while self._buffer:
root.handle(self._buffer.pop(0))
_delayed_handler = DelayedHandler()
def bootstrap_delayed_logging():
root = logging.getLogger('')
root.setLevel(logging.DEBUG)
root.addHandler(_delayed_handler)
def setup_logging(config, verbosity_level, save_debug_log): def setup_logging(config, verbosity_level, save_debug_log):
setup_root_logger()
setup_console_logging(config, verbosity_level) setup_console_logging(config, verbosity_level)
setup_log_levels(config)
if save_debug_log: if save_debug_log:
setup_debug_logging_to_file(config) setup_debug_logging_to_file(config)
if hasattr(logging, 'captureWarnings'):
# New in Python 2.7 logging.captureWarnings(True)
logging.captureWarnings(True)
if config['logging']['config_file']: if config['logging']['config_file']:
logging.config.fileConfig(config['logging']['config_file']) logging.config.fileConfig(config['logging']['config_file'])
logger = logging.getLogger('mopidy.utils.log') _delayed_handler.release()
logger.info('Starting Mopidy %s', versioning.get_version())
def setup_log_levels(config): def setup_log_levels(config):
@ -28,13 +51,8 @@ def setup_log_levels(config):
logging.getLogger(name).setLevel(level) logging.getLogger(name).setLevel(level)
def setup_root_logger():
root = logging.getLogger('')
root.setLevel(logging.DEBUG)
def setup_console_logging(config, verbosity_level): def setup_console_logging(config, verbosity_level):
if verbosity_level == -1: if verbosity_level < 0:
log_level = logging.WARNING log_level = logging.WARNING
log_format = config['logging']['console_format'] log_format = config['logging']['console_format']
elif verbosity_level >= 1: elif verbosity_level >= 1:

View File

@ -37,14 +37,17 @@ def get_or_create_dir(dir_path):
return dir_path return dir_path
def get_or_create_file(file_path): def get_or_create_file(file_path, mkdir=True, content=None):
if not isinstance(file_path, bytes): if not isinstance(file_path, bytes):
raise ValueError('Path is not a bytestring.') raise ValueError('Path is not a bytestring.')
file_path = expand_path(file_path) file_path = expand_path(file_path)
get_or_create_dir(os.path.dirname(file_path)) if mkdir:
get_or_create_dir(os.path.dirname(file_path))
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
logger.info('Creating file %s', file_path) logger.info('Creating file %s', file_path)
open(file_path, 'w').close() with open(file_path, 'w') as fh:
if content:
fh.write(content)
return file_path return file_path

92
mopidy/utils/zeroconf.py Normal file
View File

@ -0,0 +1,92 @@
from __future__ import unicode_literals
import logging
import socket
import string
logger = logging.getLogger('mopidy.utils.zeroconf')
try:
import dbus
except ImportError:
dbus = None
_AVAHI_IF_UNSPEC = -1
_AVAHI_PROTO_UNSPEC = -1
_AVAHI_PUBLISHFLAGS_NONE = 0
def _is_loopback_address(host):
return host.startswith('127.') or host == '::1'
def _convert_text_to_dbus_bytes(text):
return [dbus.Byte(ord(c)) for c in text]
class Zeroconf(object):
"""Publish a network service with Zeroconf using Avahi."""
def __init__(self, name, port, stype=None, domain=None,
host=None, text=None):
self.group = None
self.stype = stype or '_http._tcp'
self.domain = domain or ''
self.port = port
self.text = text or []
if host in ('::', '0.0.0.0'):
self.host = ''
else:
self.host = host
template = string.Template(name)
self.name = template.safe_substitute(
hostname=self.host or socket.getfqdn(), port=self.port)
def publish(self):
if _is_loopback_address(self.host):
logger.info(
'Zeroconf publish on loopback interface is not supported.')
return False
if not dbus:
logger.debug('Zeroconf publish failed: dbus not installed.')
return False
try:
bus = dbus.SystemBus()
if not bus.name_has_owner('org.freedesktop.Avahi'):
logger.debug(
'Zeroconf publish failed: Avahi service not running.')
return False
server = dbus.Interface(
bus.get_object('org.freedesktop.Avahi', '/'),
'org.freedesktop.Avahi.Server')
self.group = dbus.Interface(
bus.get_object(
'org.freedesktop.Avahi', server.EntryGroupNew()),
'org.freedesktop.Avahi.EntryGroup')
text = [_convert_text_to_dbus_bytes(t) for t in self.text]
self.group.AddService(
_AVAHI_IF_UNSPEC, _AVAHI_PROTO_UNSPEC,
dbus.UInt32(_AVAHI_PUBLISHFLAGS_NONE), self.name, self.stype,
self.domain, self.host, dbus.UInt16(self.port), text)
self.group.Commit()
return True
except dbus.exceptions.DBusException as e:
logger.debug('Zeroconf publish failed: %s', e)
return False
def unpublish(self):
if self.group:
try:
self.group.Reset()
except dbus.exceptions.DBusException as e:
logger.debug('Zeroconf unpublish failed: %s', e)
finally:
self.group = None

View File

@ -38,7 +38,6 @@ setup(
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'mopidy = mopidy.__main__:main', 'mopidy = mopidy.__main__:main',
'mopidy-scan = mopidy.scanner:main',
'mopidy-convert-config = mopidy.config.convert:main', 'mopidy-convert-config = mopidy.config.convert:main',
], ],
'mopidy.ext': [ 'mopidy.ext': [

View File

@ -3,8 +3,8 @@ from __future__ import unicode_literals
import unittest import unittest
from mopidy import exceptions from mopidy import exceptions
from mopidy.audio import scan
from mopidy.models import Track, Artist, Album from mopidy.models import Track, Artist, Album
from mopidy.scanner import Scanner, translator
from mopidy.utils import path as path_lib from mopidy.utils import path as path_lib
from tests import path_to_data_dir from tests import path_to_data_dir
@ -24,6 +24,8 @@ class TranslatorTest(unittest.TestCase):
'album': 'albumname', 'album': 'albumname',
'track-number': 1, 'track-number': 1,
'artist': 'name', 'artist': 'name',
'composer': 'composer',
'performer': 'performer',
'album-artist': 'albumartistname', 'album-artist': 'albumartistname',
'title': 'trackname', 'title': 'trackname',
'track-count': 2, 'track-count': 2,
@ -31,7 +33,9 @@ class TranslatorTest(unittest.TestCase):
'album-disc-count': 3, 'album-disc-count': 3,
'date': FakeGstDate(2006, 1, 1,), 'date': FakeGstDate(2006, 1, 1,),
'container-format': 'ID3 tag', 'container-format': 'ID3 tag',
'duration': 4531, 'genre': 'genre',
'duration': 4531000000,
'comment': 'comment',
'musicbrainz-trackid': 'mbtrackid', 'musicbrainz-trackid': 'mbtrackid',
'musicbrainz-albumid': 'mbalbumid', 'musicbrainz-albumid': 'mbalbumid',
'musicbrainz-artistid': 'mbartistid', 'musicbrainz-artistid': 'mbartistid',
@ -46,11 +50,38 @@ class TranslatorTest(unittest.TestCase):
'musicbrainz_id': 'mbalbumid', 'musicbrainz_id': 'mbalbumid',
} }
self.artist = { self.artist_single = {
'name': 'name', 'name': 'name',
'musicbrainz_id': 'mbartistid', 'musicbrainz_id': 'mbartistid',
} }
self.artist_multiple = {
'name': ['name1', 'name2'],
'musicbrainz_id': 'mbartistid',
}
self.artist = self.artist_single
self.composer_single = {
'name': 'composer',
}
self.composer_multiple = {
'name': ['composer1', 'composer2'],
}
self.composer = self.composer_single
self.performer_single = {
'name': 'performer',
}
self.performer_multiple = {
'name': ['performer1', 'performer2'],
}
self.performer = self.performer_single
self.albumartist = { self.albumartist = {
'name': 'albumartistname', 'name': 'albumartistname',
'musicbrainz_id': 'mbalbumartistid', 'musicbrainz_id': 'mbalbumartistid',
@ -60,8 +91,10 @@ class TranslatorTest(unittest.TestCase):
'uri': 'uri', 'uri': 'uri',
'name': 'trackname', 'name': 'trackname',
'date': '2006-01-01', 'date': '2006-01-01',
'genre': 'genre',
'track_no': 1, 'track_no': 1,
'disc_no': 2, 'disc_no': 2,
'comment': 'comment',
'length': 4531, 'length': 4531,
'musicbrainz_id': 'mbtrackid', 'musicbrainz_id': 'mbtrackid',
'last_modified': 1234, 'last_modified': 1234,
@ -71,12 +104,35 @@ class TranslatorTest(unittest.TestCase):
if self.albumartist: if self.albumartist:
self.album['artists'] = [Artist(**self.albumartist)] self.album['artists'] = [Artist(**self.albumartist)]
self.track['album'] = Album(**self.album) self.track['album'] = Album(**self.album)
self.track['artists'] = [Artist(**self.artist)]
if ('name' in self.artist
and not isinstance(self.artist['name'], basestring)):
self.track['artists'] = [Artist(name=artist)
for artist in self.artist['name']]
else:
self.track['artists'] = [Artist(**self.artist)]
if ('name' in self.composer
and not isinstance(self.composer['name'], basestring)):
self.track['composers'] = [Artist(name=artist)
for artist in self.composer['name']]
else:
self.track['composers'] = [Artist(**self.composer)] \
if self.composer else ''
if ('name' in self.performer
and not isinstance(self.performer['name'], basestring)):
self.track['performers'] = [Artist(name=artist)
for artist in self.performer['name']]
else:
self.track['performers'] = [Artist(**self.performer)] \
if self.performer else ''
return Track(**self.track) return Track(**self.track)
def check(self): def check(self):
expected = self.build_track() expected = self.build_track()
actual = translator(self.data) actual = scan.audio_data_to_track(self.data)
self.assertEqual(expected, actual) self.assertEqual(expected, actual)
def test_basic_data(self): def test_basic_data(self):
@ -117,11 +173,37 @@ class TranslatorTest(unittest.TestCase):
del self.artist['name'] del self.artist['name']
self.check() self.check()
def test_missing_composer_name(self):
del self.data['composer']
del self.composer['name']
self.check()
def test_multiple_track_composers(self):
self.data['composer'] = ['composer1', 'composer2']
self.composer = self.composer_multiple
self.check()
def test_multiple_track_performers(self):
self.data['performer'] = ['performer1', 'performer2']
self.performer = self.performer_multiple
self.check()
def test_missing_performer_name(self):
del self.data['performer']
del self.performer['name']
self.check()
def test_missing_artist_musicbrainz_id(self): def test_missing_artist_musicbrainz_id(self):
del self.data['musicbrainz-artistid'] del self.data['musicbrainz-artistid']
del self.artist['musicbrainz_id'] del self.artist['musicbrainz_id']
self.check() self.check()
def test_multiple_track_artists(self):
self.data['artist'] = ['name1', 'name2']
self.data['musicbrainz-artistid'] = 'mbartistid'
self.artist = self.artist_multiple
self.check()
def test_missing_album_artist(self): def test_missing_album_artist(self):
del self.data['album-artist'] del self.data['album-artist']
del self.albumartist['name'] del self.albumartist['name']
@ -132,6 +214,11 @@ class TranslatorTest(unittest.TestCase):
del self.albumartist['musicbrainz_id'] del self.albumartist['musicbrainz_id']
self.check() self.check()
def test_missing_genre(self):
del self.data['genre']
del self.track['genre']
self.check()
def test_missing_date(self): def test_missing_date(self):
del self.data['date'] del self.data['date']
del self.track['date'] del self.track['date']
@ -142,6 +229,11 @@ class TranslatorTest(unittest.TestCase):
del self.track['date'] del self.track['date']
self.check() self.check()
def test_missing_comment(self):
del self.data['comment']
del self.track['comment']
self.check()
class ScannerTest(unittest.TestCase): class ScannerTest(unittest.TestCase):
def setUp(self): def setUp(self):
@ -151,7 +243,7 @@ class ScannerTest(unittest.TestCase):
def scan(self, path): def scan(self, path):
paths = path_lib.find_files(path_to_data_dir(path)) paths = path_lib.find_files(path_to_data_dir(path))
uris = (path_lib.path_to_uri(p) for p in paths) uris = (path_lib.path_to_uri(p) for p in paths)
scanner = Scanner() scanner = scan.Scanner()
for uri in uris: for uri in uris:
key = uri[len('file://'):] key = uri[len('file://'):]
try: try:
@ -182,8 +274,8 @@ class ScannerTest(unittest.TestCase):
def test_duration_is_set(self): def test_duration_is_set(self):
self.scan('scanner/simple') self.scan('scanner/simple')
self.check('scanner/simple/song1.mp3', 'duration', 4680) self.check('scanner/simple/song1.mp3', 'duration', 4680000000)
self.check('scanner/simple/song1.ogg', 'duration', 4680) self.check('scanner/simple/song1.ogg', 'duration', 4680000000)
def test_artist_is_set(self): def test_artist_is_set(self):
self.scan('scanner/simple') self.scan('scanner/simple')

View File

@ -20,12 +20,15 @@ class LocalLibraryProviderTest(unittest.TestCase):
Artist(name='artist2'), Artist(name='artist2'),
Artist(name='artist3'), Artist(name='artist3'),
Artist(name='artist4'), Artist(name='artist4'),
Artist(name='artist5'),
Artist(name='artist6'),
] ]
albums = [ albums = [
Album(name='album1', artists=[artists[0]]), Album(name='album1', artists=[artists[0]]),
Album(name='album2', artists=[artists[1]]), Album(name='album2', artists=[artists[1]]),
Album(name='album3', artists=[artists[2]]), Album(name='album3', artists=[artists[2]]),
Album(name='album4'),
] ]
tracks = [ tracks = [
@ -41,6 +44,17 @@ class LocalLibraryProviderTest(unittest.TestCase):
uri='local:track:path3', name='track3', uri='local:track:path3', name='track3',
artists=[artists[3]], album=albums[2], artists=[artists[3]], album=albums[2],
date='2003', length=4000, track_no=3), date='2003', length=4000, track_no=3),
Track(
uri='local:track:path4', name='track4',
artists=[artists[2]], album=albums[3],
date='2004', length=60000, track_no=4,
comment='This is a fantastic track'),
Track(
uri='local:track:path5', name='track5', genre='genre1',
album=albums[3], length=4000, composers=[artists[4]]),
Track(
uri='local:track:path6', name='track6', genre='genre2',
album=albums[3], length=4000, performers=[artists[5]]),
] ]
config = { config = {
@ -102,7 +116,7 @@ class LocalLibraryProviderTest(unittest.TestCase):
self.assertEqual(tracks, []) self.assertEqual(tracks, [])
def test_find_exact_no_hits(self): def test_find_exact_no_hits(self):
result = self.library.find_exact(track=['unknown track']) result = self.library.find_exact(track_name=['unknown track'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(artist=['unknown artist']) result = self.library.find_exact(artist=['unknown artist'])
@ -111,18 +125,30 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(albumartist=['unknown albumartist']) result = self.library.find_exact(albumartist=['unknown albumartist'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(album=['unknown artist']) result = self.library.find_exact(composer=['unknown composer'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(performer=['unknown performer'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(album=['unknown album'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(date=['1990']) result = self.library.find_exact(date=['1990'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(genre=['unknown genre'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(track_no=['9']) result = self.library.find_exact(track_no=['9'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(track_no=['no_match']) result = self.library.find_exact(track_no=['no_match'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(comment=['fake comment'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(uri=['fake uri']) result = self.library.find_exact(uri=['fake uri'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
@ -138,11 +164,11 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(uri=track_2_uri) result = self.library.find_exact(uri=track_2_uri)
self.assertEqual(list(result[0].tracks), self.tracks[1:2]) self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_find_exact_track(self): def test_find_exact_track_name(self):
result = self.library.find_exact(track=['track1']) result = self.library.find_exact(track_name=['track1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1]) self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.find_exact(track=['track2']) result = self.library.find_exact(track_name=['track2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2]) self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_find_exact_artist(self): def test_find_exact_artist(self):
@ -152,6 +178,23 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(artist=['artist2']) result = self.library.find_exact(artist=['artist2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2]) self.assertEqual(list(result[0].tracks), self.tracks[1:2])
result = self.library.find_exact(artist=['artist3'])
self.assertEqual(list(result[0].tracks), self.tracks[3:4])
def test_find_exact_composer(self):
result = self.library.find_exact(composer=['artist5'])
self.assertEqual(list(result[0].tracks), self.tracks[4:5])
result = self.library.find_exact(composer=['artist6'])
self.assertEqual(list(result[0].tracks), [])
def test_find_exact_performer(self):
result = self.library.find_exact(performer=['artist6'])
self.assertEqual(list(result[0].tracks), self.tracks[5:6])
result = self.library.find_exact(performer=['artist5'])
self.assertEqual(list(result[0].tracks), [])
def test_find_exact_album(self): def test_find_exact_album(self):
result = self.library.find_exact(album=['album1']) result = self.library.find_exact(album=['album1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1]) self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -179,6 +222,13 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(track_no=['2']) result = self.library.find_exact(track_no=['2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2]) self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_find_exact_genre(self):
result = self.library.find_exact(genre=['genre1'])
self.assertEqual(list(result[0].tracks), self.tracks[4:5])
result = self.library.find_exact(genre=['genre2'])
self.assertEqual(list(result[0].tracks), self.tracks[5:6])
def test_find_exact_date(self): def test_find_exact_date(self):
result = self.library.find_exact(date=['2001']) result = self.library.find_exact(date=['2001'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
@ -189,6 +239,15 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(date=['2002']) result = self.library.find_exact(date=['2002'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2]) self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_find_exact_comment(self):
result = self.library.find_exact(
comment=['This is a fantastic track'])
self.assertEqual(list(result[0].tracks), self.tracks[3:4])
result = self.library.find_exact(
comment=['This is a fantastic'])
self.assertEqual(list(result[0].tracks), [])
def test_find_exact_any(self): def test_find_exact_any(self):
# Matches on track artist # Matches on track artist
result = self.library.find_exact(any=['artist1']) result = self.library.find_exact(any=['artist1'])
@ -197,7 +256,7 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(any=['artist2']) result = self.library.find_exact(any=['artist2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2]) self.assertEqual(list(result[0].tracks), self.tracks[1:2])
# Matches on track # Matches on track name
result = self.library.find_exact(any=['track1']) result = self.library.find_exact(any=['track1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1]) self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -210,12 +269,33 @@ class LocalLibraryProviderTest(unittest.TestCase):
# Matches on track album artists # Matches on track album artists
result = self.library.find_exact(any=['artist3']) result = self.library.find_exact(any=['artist3'])
self.assertEqual(list(result[0].tracks), self.tracks[2:3]) self.assertEqual(
list(result[0].tracks), [self.tracks[3], self.tracks[2]])
# Matches on track year # Matches on track composer
result = self.library.find_exact(any=['artist5'])
self.assertEqual(list(result[0].tracks), self.tracks[4:5])
# Matches on track performer
result = self.library.find_exact(any=['artist6'])
self.assertEqual(list(result[0].tracks), self.tracks[5:6])
# Matches on track genre
result = self.library.find_exact(any=['genre1'])
self.assertEqual(list(result[0].tracks), self.tracks[4:5])
result = self.library.find_exact(any=['genre2'])
self.assertEqual(list(result[0].tracks), self.tracks[5:6])
# Matches on track date
result = self.library.find_exact(any=['2002']) result = self.library.find_exact(any=['2002'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2]) self.assertEqual(list(result[0].tracks), self.tracks[1:2])
# Matches on track comment
result = self.library.find_exact(
any=['This is a fantastic track'])
self.assertEqual(list(result[0].tracks), self.tracks[3:4])
# Matches on URI # Matches on URI
result = self.library.find_exact(any=['local:track:path1']) result = self.library.find_exact(any=['local:track:path1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1]) self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -231,7 +311,13 @@ class LocalLibraryProviderTest(unittest.TestCase):
test = lambda: self.library.find_exact(albumartist=['']) test = lambda: self.library.find_exact(albumartist=[''])
self.assertRaises(LookupError, test) self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(track=['']) test = lambda: self.library.find_exact(track_name=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(composer=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(performer=[''])
self.assertRaises(LookupError, test) self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(album=['']) test = lambda: self.library.find_exact(album=[''])
@ -240,14 +326,20 @@ class LocalLibraryProviderTest(unittest.TestCase):
test = lambda: self.library.find_exact(track_no=['']) test = lambda: self.library.find_exact(track_no=[''])
self.assertRaises(LookupError, test) self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(genre=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(date=['']) test = lambda: self.library.find_exact(date=[''])
self.assertRaises(LookupError, test) self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(comment=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(any=['']) test = lambda: self.library.find_exact(any=[''])
self.assertRaises(LookupError, test) self.assertRaises(LookupError, test)
def test_search_no_hits(self): def test_search_no_hits(self):
result = self.library.search(track=['unknown track']) result = self.library.search(track_name=['unknown track'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
result = self.library.search(artist=['unknown artist']) result = self.library.search(artist=['unknown artist'])
@ -256,7 +348,13 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(albumartist=['unknown albumartist']) result = self.library.search(albumartist=['unknown albumartist'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
result = self.library.search(album=['unknown artist']) result = self.library.search(composer=['unknown composer'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(performer=['unknown performer'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(album=['unknown album'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
result = self.library.search(track_no=['9']) result = self.library.search(track_no=['9'])
@ -265,9 +363,15 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(track_no=['no_match']) result = self.library.search(track_no=['no_match'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
result = self.library.search(genre=['unknown genre'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(date=['unknown date']) result = self.library.search(date=['unknown date'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
result = self.library.search(comment=['unknown comment'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(uri=['unknown uri']) result = self.library.search(uri=['unknown uri'])
self.assertEqual(list(result[0].tracks), []) self.assertEqual(list(result[0].tracks), [])
@ -281,11 +385,11 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(uri=['TH2']) result = self.library.search(uri=['TH2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2]) self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_search_track(self): def test_search_track_name(self):
result = self.library.search(track=['Rack1']) result = self.library.search(track_name=['Rack1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1]) self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.search(track=['Rack2']) result = self.library.search(track_name=['Rack2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2]) self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_search_artist(self): def test_search_artist(self):
@ -308,6 +412,14 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(albumartist=['Tist3']) result = self.library.search(albumartist=['Tist3'])
self.assertEqual(list(result[0].tracks), [self.tracks[2]]) self.assertEqual(list(result[0].tracks), [self.tracks[2]])
def test_search_composer(self):
result = self.library.search(composer=['Tist5'])
self.assertEqual(list(result[0].tracks), self.tracks[4:5])
def test_search_performer(self):
result = self.library.search(performer=['Tist6'])
self.assertEqual(list(result[0].tracks), self.tracks[5:6])
def test_search_album(self): def test_search_album(self):
result = self.library.search(album=['Bum1']) result = self.library.search(album=['Bum1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1]) self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -315,6 +427,13 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(album=['Bum2']) result = self.library.search(album=['Bum2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2]) self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_search_genre(self):
result = self.library.search(genre=['Enre1'])
self.assertEqual(list(result[0].tracks), self.tracks[4:5])
result = self.library.search(genre=['Enre2'])
self.assertEqual(list(result[0].tracks), self.tracks[5:6])
def test_search_date(self): def test_search_date(self):
result = self.library.search(date=['2001']) result = self.library.search(date=['2001'])
self.assertEqual(list(result[0].tracks), self.tracks[:1]) self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -335,11 +454,26 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(track_no=['2']) result = self.library.search(track_no=['2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2]) self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_search_comment(self):
result = self.library.search(comment=['fantastic'])
self.assertEqual(list(result[0].tracks), self.tracks[3:4])
result = self.library.search(comment=['antasti'])
self.assertEqual(list(result[0].tracks), self.tracks[3:4])
def test_search_any(self): def test_search_any(self):
# Matches on track artist # Matches on track artist
result = self.library.search(any=['Tist1']) result = self.library.search(any=['Tist1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1]) self.assertEqual(list(result[0].tracks), self.tracks[:1])
# Matches on track composer
result = self.library.search(any=['Tist5'])
self.assertEqual(list(result[0].tracks), self.tracks[4:5])
# Matches on track performer
result = self.library.search(any=['Tist6'])
self.assertEqual(list(result[0].tracks), self.tracks[5:6])
# Matches on track # Matches on track
result = self.library.search(any=['Rack1']) result = self.library.search(any=['Rack1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1]) self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -353,7 +487,22 @@ class LocalLibraryProviderTest(unittest.TestCase):
# Matches on track album artists # Matches on track album artists
result = self.library.search(any=['Tist3']) result = self.library.search(any=['Tist3'])
self.assertEqual(list(result[0].tracks), self.tracks[2:3]) self.assertEqual(
list(result[0].tracks), [self.tracks[3], self.tracks[2]])
# Matches on track genre
result = self.library.search(any=['Enre1'])
self.assertEqual(list(result[0].tracks), self.tracks[4:5])
result = self.library.search(any=['Enre2'])
self.assertEqual(list(result[0].tracks), self.tracks[5:6])
# Matches on track comment
result = self.library.search(any=['fanta'])
self.assertEqual(list(result[0].tracks), self.tracks[3:4])
result = self.library.search(any=['is a fan'])
self.assertEqual(list(result[0].tracks), self.tracks[3:4])
# Matches on URI # Matches on URI
result = self.library.search(any=['TH1']) result = self.library.search(any=['TH1'])
@ -370,15 +519,27 @@ class LocalLibraryProviderTest(unittest.TestCase):
test = lambda: self.library.search(albumartist=['']) test = lambda: self.library.search(albumartist=[''])
self.assertRaises(LookupError, test) self.assertRaises(LookupError, test)
test = lambda: self.library.search(track=['']) test = lambda: self.library.search(composer=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(performer=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(track_name=[''])
self.assertRaises(LookupError, test) self.assertRaises(LookupError, test)
test = lambda: self.library.search(album=['']) test = lambda: self.library.search(album=[''])
self.assertRaises(LookupError, test) self.assertRaises(LookupError, test)
test = lambda: self.library.search(genre=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(date=['']) test = lambda: self.library.search(date=[''])
self.assertRaises(LookupError, test) self.assertRaises(LookupError, test)
test = lambda: self.library.search(comment=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(uri=['']) test = lambda: self.library.search(uri=[''])
self.assertRaises(LookupError, test) self.assertRaises(LookupError, test)

View File

@ -71,34 +71,34 @@ class LocalTracklistProviderTest(unittest.TestCase):
def test_filter_by_tlid(self): def test_filter_by_tlid(self):
tl_track = self.controller.tl_tracks[1] tl_track = self.controller.tl_tracks[1]
self.assertEqual( self.assertEqual(
[tl_track], self.controller.filter(tlid=tl_track.tlid)) [tl_track], self.controller.filter(tlid=[tl_track.tlid]))
@populate_tracklist @populate_tracklist
def test_filter_by_uri(self): def test_filter_by_uri(self):
tl_track = self.controller.tl_tracks[1] tl_track = self.controller.tl_tracks[1]
self.assertEqual( self.assertEqual(
[tl_track], self.controller.filter(uri=tl_track.track.uri)) [tl_track], self.controller.filter(uri=[tl_track.track.uri]))
@populate_tracklist @populate_tracklist
def test_filter_by_uri_returns_nothing_for_invalid_uri(self): def test_filter_by_uri_returns_nothing_for_invalid_uri(self):
self.assertEqual([], self.controller.filter(uri='foobar')) self.assertEqual([], self.controller.filter(uri=['foobar']))
def test_filter_by_uri_returns_single_match(self): def test_filter_by_uri_returns_single_match(self):
track = Track(uri='a') track = Track(uri='a')
self.controller.add([Track(uri='z'), track, Track(uri='y')]) self.controller.add([Track(uri='z'), track, Track(uri='y')])
self.assertEqual(track, self.controller.filter(uri='a')[0].track) self.assertEqual(track, self.controller.filter(uri=['a'])[0].track)
def test_filter_by_uri_returns_multiple_matches(self): def test_filter_by_uri_returns_multiple_matches(self):
track = Track(uri='a') track = Track(uri='a')
self.controller.add([Track(uri='z'), track, track]) self.controller.add([Track(uri='z'), track, track])
tl_tracks = self.controller.filter(uri='a') tl_tracks = self.controller.filter(uri=['a'])
self.assertEqual(track, tl_tracks[0].track) self.assertEqual(track, tl_tracks[0].track)
self.assertEqual(track, tl_tracks[1].track) self.assertEqual(track, tl_tracks[1].track)
def test_filter_by_uri_returns_nothing_if_no_match(self): def test_filter_by_uri_returns_nothing_if_no_match(self):
self.controller.playlist = Playlist( self.controller.playlist = Playlist(
tracks=[Track(uri='z'), Track(uri='y')]) tracks=[Track(uri=['z']), Track(uri=['y'])])
self.assertEqual([], self.controller.filter(uri='a')) self.assertEqual([], self.controller.filter(uri=['a']))
def test_filter_by_multiple_criteria_returns_elements_matching_all(self): def test_filter_by_multiple_criteria_returns_elements_matching_all(self):
track1 = Track(uri='a', name='x') track1 = Track(uri='a', name='x')
@ -106,18 +106,18 @@ class LocalTracklistProviderTest(unittest.TestCase):
track3 = Track(uri='b', name='y') track3 = Track(uri='b', name='y')
self.controller.add([track1, track2, track3]) self.controller.add([track1, track2, track3])
self.assertEqual( self.assertEqual(
track1, self.controller.filter(uri='a', name='x')[0].track) track1, self.controller.filter(uri=['a'], name=['x'])[0].track)
self.assertEqual( self.assertEqual(
track2, self.controller.filter(uri='b', name='x')[0].track) track2, self.controller.filter(uri=['b'], name=['x'])[0].track)
self.assertEqual( self.assertEqual(
track3, self.controller.filter(uri='b', name='y')[0].track) track3, self.controller.filter(uri=['b'], name=['y'])[0].track)
def test_filter_by_criteria_that_is_not_present_in_all_elements(self): def test_filter_by_criteria_that_is_not_present_in_all_elements(self):
track1 = Track() track1 = Track()
track2 = Track(uri='b') track2 = Track(uri='b')
track3 = Track() track3 = Track()
self.controller.add([track1, track2, track3]) self.controller.add([track1, track2, track3])
self.assertEqual(track2, self.controller.filter(uri='b')[0].track) self.assertEqual(track2, self.controller.filter(uri=['b'])[0].track)
@populate_tracklist @populate_tracklist
def test_clear(self): def test_clear(self):
@ -227,17 +227,29 @@ class LocalTracklistProviderTest(unittest.TestCase):
track1 = self.controller.tracks[1] track1 = self.controller.tracks[1]
track2 = self.controller.tracks[2] track2 = self.controller.tracks[2]
version = self.controller.version version = self.controller.version
self.controller.remove(uri=track1.uri) self.controller.remove(uri=[track1.uri])
self.assertLess(version, self.controller.version) self.assertLess(version, self.controller.version)
self.assertNotIn(track1, self.controller.tracks) self.assertNotIn(track1, self.controller.tracks)
self.assertEqual(track2, self.controller.tracks[1]) self.assertEqual(track2, self.controller.tracks[1])
@populate_tracklist @populate_tracklist
def test_removing_track_that_does_not_exist_does_nothing(self): def test_removing_track_that_does_not_exist_does_nothing(self):
self.controller.remove(uri='/nonexistant') self.controller.remove(uri=['/nonexistant'])
def test_removing_from_empty_playlist_does_nothing(self): def test_removing_from_empty_playlist_does_nothing(self):
self.controller.remove(uri='/nonexistant') self.controller.remove(uri=['/nonexistant'])
@populate_tracklist
def test_remove_lists(self):
track0 = self.controller.tracks[0]
track1 = self.controller.tracks[1]
track2 = self.controller.tracks[2]
version = self.controller.version
self.controller.remove(uri=[track0.uri, track2.uri])
self.assertLess(version, self.controller.version)
self.assertNotIn(track0, self.controller.tracks)
self.assertNotIn(track2, self.controller.tracks)
self.assertEqual(track1, self.controller.tracks[0])
@populate_tracklist @populate_tracklist
def test_shuffle(self): def test_shuffle(self):

View File

@ -93,28 +93,30 @@ class URItoM3UTest(unittest.TestCase):
expected_artists = [Artist(name='name')] expected_artists = [Artist(name='name')]
expected_albums = [ expected_albums = [
Album(name='albumname', artists=expected_artists, num_tracks=2)] Album(name='albumname', artists=expected_artists, num_tracks=2),
Album(name='albumname', num_tracks=2),
]
expected_tracks = [] expected_tracks = []
def generate_track(path, ident): def generate_track(path, ident, album_id):
uri = 'local:track:%s' % path uri = 'local:track:%s' % path
track = Track( track = Track(
uri=uri, name='trackname', artists=expected_artists, uri=uri, name='trackname', artists=expected_artists,
album=expected_albums[0], track_no=1, date='2006', length=4000, album=expected_albums[album_id], track_no=1, date='2006', length=4000,
last_modified=1272319626) last_modified=1272319626)
expected_tracks.append(track) expected_tracks.append(track)
generate_track('song1.mp3', 6) generate_track('song1.mp3', 6, 0)
generate_track('song2.mp3', 7) generate_track('song2.mp3', 7, 0)
generate_track('song3.mp3', 8) generate_track('song3.mp3', 8, 1)
generate_track('subdir1/song4.mp3', 2) generate_track('subdir1/song4.mp3', 2, 0)
generate_track('subdir1/song5.mp3', 3) generate_track('subdir1/song5.mp3', 3, 0)
generate_track('subdir2/song6.mp3', 4) generate_track('subdir2/song6.mp3', 4, 1)
generate_track('subdir2/song7.mp3', 5) generate_track('subdir2/song7.mp3', 5, 1)
generate_track('subdir1/subsubdir/song8.mp3', 0) generate_track('subdir1/subsubdir/song8.mp3', 0, 0)
generate_track('subdir1/subsubdir/song9.mp3', 1) generate_track('subdir1/subsubdir/song9.mp3', 1, 1)
class MPDTagCacheToTracksTest(unittest.TestCase): class MPDTagCacheToTracksTest(unittest.TestCase):
@ -145,7 +147,9 @@ class MPDTagCacheToTracksTest(unittest.TestCase):
album = Album(name='æøå', artists=artists) album = Album(name='æøå', artists=artists)
track = Track( track = Track(
uri='local:track:song1.mp3', name='æøå', artists=artists, uri='local:track:song1.mp3', name='æøå', artists=artists,
album=album, length=4000, last_modified=1272319626) composers=artists, performers=artists, genre='æøå',
album=album, length=4000, last_modified=1272319626,
comment='æøå&^`ൂ㔶')
self.assertEqual(track, list(tracks)[0]) self.assertEqual(track, list(tracks)[0])

View File

@ -1,6 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import argparse import argparse
import mock
import unittest import unittest
from mopidy import commands from mopidy import commands
@ -42,3 +43,450 @@ class ConfigOverrideTypeTest(unittest.TestCase):
self.assertRaises( self.assertRaises(
argparse.ArgumentTypeError, argparse.ArgumentTypeError,
commands.config_override_type, b'section') commands.config_override_type, b'section')
class CommandParsingTest(unittest.TestCase):
def setUp(self):
self.exit_patcher = mock.patch.object(commands.Command, 'exit')
self.exit_mock = self.exit_patcher.start()
self.exit_mock.side_effect = SystemExit
def tearDown(self):
self.exit_patcher.stop()
def test_command_parsing_returns_namespace(self):
cmd = commands.Command()
self.assertIsInstance(cmd.parse([]), argparse.Namespace)
def test_command_parsing_does_not_contain_args(self):
cmd = commands.Command()
result = cmd.parse([])
self.assertFalse(hasattr(result, '_args'))
def test_unknown_options_bails(self):
cmd = commands.Command()
with self.assertRaises(SystemExit):
cmd.parse(['--foobar'])
def test_invalid_sub_command_bails(self):
cmd = commands.Command()
with self.assertRaises(SystemExit):
cmd.parse(['foo'])
def test_command_arguments(self):
cmd = commands.Command()
cmd.add_argument('--bar')
result = cmd.parse(['--bar', 'baz'])
self.assertEqual(result.bar, 'baz')
def test_command_arguments_and_sub_command(self):
child = commands.Command()
child.add_argument('--baz')
cmd = commands.Command()
cmd.add_argument('--bar')
cmd.add_child('foo', child)
result = cmd.parse(['--bar', 'baz', 'foo'])
self.assertEqual(result.bar, 'baz')
self.assertEqual(result.baz, None)
def test_subcommand_may_have_positional(self):
child = commands.Command()
child.add_argument('bar')
cmd = commands.Command()
cmd.add_child('foo', child)
result = cmd.parse(['foo', 'baz'])
self.assertEqual(result.bar, 'baz')
def test_subcommand_may_have_remainder(self):
child = commands.Command()
child.add_argument('bar', nargs=argparse.REMAINDER)
cmd = commands.Command()
cmd.add_child('foo', child)
result = cmd.parse(['foo', 'baz', 'bep', 'bop'])
self.assertEqual(result.bar, ['baz', 'bep', 'bop'])
def test_result_stores_choosen_command(self):
child = commands.Command()
cmd = commands.Command()
cmd.add_child('foo', child)
result = cmd.parse(['foo'])
self.assertEqual(result.command, child)
result = cmd.parse([])
self.assertEqual(result.command, cmd)
child2 = commands.Command()
cmd.add_child('bar', child2)
subchild = commands.Command()
child.add_child('baz', subchild)
result = cmd.parse(['bar'])
self.assertEqual(result.command, child2)
result = cmd.parse(['foo', 'baz'])
self.assertEqual(result.command, subchild)
def test_invalid_type(self):
cmd = commands.Command()
cmd.add_argument('--bar', type=int)
with self.assertRaises(SystemExit):
cmd.parse(['--bar', b'zero'], prog='foo')
self.exit_mock.assert_called_once_with(
1, "argument --bar: invalid int value: 'zero'",
'usage: foo [--bar BAR]')
@mock.patch('sys.argv')
def test_command_error_usage_prog(self, argv_mock):
argv_mock.__getitem__.return_value = '/usr/bin/foo'
cmd = commands.Command()
cmd.add_argument('--bar', required=True)
with self.assertRaises(SystemExit):
cmd.parse([])
self.exit_mock.assert_called_once_with(
mock.ANY, mock.ANY, 'usage: foo --bar BAR')
self.exit_mock.reset_mock()
with self.assertRaises(SystemExit):
cmd.parse([], prog='baz')
self.exit_mock.assert_called_once_with(
mock.ANY, mock.ANY, 'usage: baz --bar BAR')
def test_missing_required(self):
cmd = commands.Command()
cmd.add_argument('--bar', required=True)
with self.assertRaises(SystemExit):
cmd.parse([], prog='foo')
self.exit_mock.assert_called_once_with(
1, 'argument --bar is required', 'usage: foo --bar BAR')
def test_missing_positionals(self):
cmd = commands.Command()
cmd.add_argument('bar')
with self.assertRaises(SystemExit):
cmd.parse([], prog='foo')
self.exit_mock.assert_called_once_with(
1, 'too few arguments', 'usage: foo bar')
def test_missing_positionals_subcommand(self):
child = commands.Command()
child.add_argument('baz')
cmd = commands.Command()
cmd.add_child('bar', child)
with self.assertRaises(SystemExit):
cmd.parse(['bar'], prog='foo')
self.exit_mock.assert_called_once_with(
1, 'too few arguments', 'usage: foo bar baz')
def test_unknown_command(self):
cmd = commands.Command()
with self.assertRaises(SystemExit):
cmd.parse(['--help'], prog='foo')
self.exit_mock.assert_called_once_with(
1, 'unrecognized arguments: --help', 'usage: foo')
def test_invalid_subcommand(self):
cmd = commands.Command()
cmd.add_child('baz', commands.Command())
with self.assertRaises(SystemExit):
cmd.parse(['bar'], prog='foo')
self.exit_mock.assert_called_once_with(
1, 'unrecognized command: bar', 'usage: foo')
def test_set(self):
cmd = commands.Command()
cmd.set(foo='bar')
result = cmd.parse([])
self.assertEqual(result.foo, 'bar')
def test_set_propegate(self):
child = commands.Command()
cmd = commands.Command()
cmd.set(foo='bar')
cmd.add_child('command', child)
result = cmd.parse(['command'])
self.assertEqual(result.foo, 'bar')
def test_innermost_set_wins(self):
child = commands.Command()
child.set(foo='bar', baz=1)
cmd = commands.Command()
cmd.set(foo='baz', baz=None)
cmd.add_child('command', child)
result = cmd.parse(['command'])
self.assertEqual(result.foo, 'bar')
self.assertEqual(result.baz, 1)
def test_help_action_works(self):
cmd = commands.Command()
cmd.add_argument('-h', action='help')
cmd.format_help = mock.Mock()
with self.assertRaises(SystemExit):
cmd.parse(['-h'])
cmd.format_help.assert_called_once_with(mock.ANY)
self.exit_mock.assert_called_once_with(0, cmd.format_help.return_value)
class UsageTest(unittest.TestCase):
@mock.patch('sys.argv')
def test_prog_name_default_and_override(self, argv_mock):
argv_mock.__getitem__.return_value = '/usr/bin/foo'
cmd = commands.Command()
self.assertEqual('usage: foo', cmd.format_usage().strip())
self.assertEqual('usage: baz', cmd.format_usage('baz').strip())
def test_basic_usage(self):
cmd = commands.Command()
self.assertEqual('usage: foo', cmd.format_usage('foo').strip())
cmd.add_argument('-h', '--help', action='store_true')
self.assertEqual('usage: foo [-h]', cmd.format_usage('foo').strip())
cmd.add_argument('bar')
self.assertEqual('usage: foo [-h] bar',
cmd.format_usage('foo').strip())
def test_nested_usage(self):
child = commands.Command()
cmd = commands.Command()
cmd.add_child('bar', child)
self.assertEqual('usage: foo', cmd.format_usage('foo').strip())
self.assertEqual('usage: foo bar', cmd.format_usage('foo bar').strip())
cmd.add_argument('-h', '--help', action='store_true')
self.assertEqual('usage: foo bar',
child.format_usage('foo bar').strip())
child.add_argument('-h', '--help', action='store_true')
self.assertEqual('usage: foo bar [-h]',
child.format_usage('foo bar').strip())
class HelpTest(unittest.TestCase):
@mock.patch('sys.argv')
def test_prog_name_default_and_override(self, argv_mock):
argv_mock.__getitem__.return_value = '/usr/bin/foo'
cmd = commands.Command()
self.assertEqual('usage: foo', cmd.format_help().strip())
self.assertEqual('usage: bar', cmd.format_help('bar').strip())
def test_command_without_documenation_or_options(self):
cmd = commands.Command()
self.assertEqual('usage: bar', cmd.format_help('bar').strip())
def test_command_with_option(self):
cmd = commands.Command()
cmd.add_argument('-h', '--help', action='store_true',
help='show this message')
expected = ('usage: foo [-h]\n\n'
'OPTIONS:\n\n'
' -h, --help show this message')
self.assertEqual(expected, cmd.format_help('foo').strip())
def test_command_with_option_and_positional(self):
cmd = commands.Command()
cmd.add_argument('-h', '--help', action='store_true',
help='show this message')
cmd.add_argument('bar', help='some help text')
expected = ('usage: foo [-h] bar\n\n'
'OPTIONS:\n\n'
' -h, --help show this message\n'
' bar some help text')
self.assertEqual(expected, cmd.format_help('foo').strip())
def test_command_with_documentation(self):
cmd = commands.Command()
cmd.help = 'some text about everything this command does.'
expected = ('usage: foo\n\n'
'some text about everything this command does.')
self.assertEqual(expected, cmd.format_help('foo').strip())
def test_command_with_documentation_and_option(self):
cmd = commands.Command()
cmd.help = 'some text about everything this command does.'
cmd.add_argument('-h', '--help', action='store_true',
help='show this message')
expected = ('usage: foo [-h]\n\n'
'some text about everything this command does.\n\n'
'OPTIONS:\n\n'
' -h, --help show this message')
self.assertEqual(expected, cmd.format_help('foo').strip())
def test_subcommand_without_documentation_or_options(self):
child = commands.Command()
cmd = commands.Command()
cmd.add_child('bar', child)
self.assertEqual('usage: foo', cmd.format_help('foo').strip())
def test_subcommand_with_documentation_shown(self):
child = commands.Command()
child.help = 'some text about everything this command does.'
cmd = commands.Command()
cmd.add_child('bar', child)
expected = ('usage: foo\n\n'
'COMMANDS:\n\n'
'bar\n\n'
' some text about everything this command does.')
self.assertEqual(expected, cmd.format_help('foo').strip())
def test_subcommand_with_options_shown(self):
child = commands.Command()
child.add_argument('-h', '--help', action='store_true',
help='show this message')
cmd = commands.Command()
cmd.add_child('bar', child)
expected = ('usage: foo\n\n'
'COMMANDS:\n\n'
'bar [-h]\n\n'
' -h, --help show this message')
self.assertEqual(expected, cmd.format_help('foo').strip())
def test_subcommand_with_positional_shown(self):
child = commands.Command()
child.add_argument('baz', help='the great and wonderful')
cmd = commands.Command()
cmd.add_child('bar', child)
expected = ('usage: foo\n\n'
'COMMANDS:\n\n'
'bar baz\n\n'
' baz the great and wonderful')
self.assertEqual(expected, cmd.format_help('foo').strip())
def test_subcommand_with_options_and_documentation(self):
child = commands.Command()
child.help = ' some text about everything this command does.'
child.add_argument('-h', '--help', action='store_true',
help='show this message')
cmd = commands.Command()
cmd.add_child('bar', child)
expected = ('usage: foo\n\n'
'COMMANDS:\n\n'
'bar [-h]\n\n'
' some text about everything this command does.\n\n'
' -h, --help show this message')
self.assertEqual(expected, cmd.format_help('foo').strip())
def test_nested_subcommands_with_options(self):
subchild = commands.Command()
subchild.add_argument('--test', help='the great and wonderful')
child = commands.Command()
child.add_child('baz', subchild)
child.add_argument('-h', '--help', action='store_true',
help='show this message')
cmd = commands.Command()
cmd.add_child('bar', child)
expected = ('usage: foo\n\n'
'COMMANDS:\n\n'
'bar [-h]\n\n'
' -h, --help show this message\n\n'
'bar baz [--test TEST]\n\n'
' --test TEST the great and wonderful')
self.assertEqual(expected, cmd.format_help('foo').strip())
def test_nested_subcommands_skipped_intermediate(self):
subchild = commands.Command()
subchild.add_argument('--test', help='the great and wonderful')
child = commands.Command()
child.add_child('baz', subchild)
cmd = commands.Command()
cmd.add_child('bar', child)
expected = ('usage: foo\n\n'
'COMMANDS:\n\n'
'bar baz [--test TEST]\n\n'
' --test TEST the great and wonderful')
self.assertEqual(expected, cmd.format_help('foo').strip())
def test_command_with_option_and_subcommand_with_option(self):
child = commands.Command()
child.add_argument('--test', help='the great and wonderful')
cmd = commands.Command()
cmd.add_argument('-h', '--help', action='store_true',
help='show this message')
cmd.add_child('bar', child)
expected = ('usage: foo [-h]\n\n'
'OPTIONS:\n\n'
' -h, --help show this message\n\n'
'COMMANDS:\n\n'
'bar [--test TEST]\n\n'
' --test TEST the great and wonderful')
self.assertEqual(expected, cmd.format_help('foo').strip())
def test_command_with_options_doc_and_subcommand_with_option_and_doc(self):
child = commands.Command()
child.help = 'some text about this sub-command.'
child.add_argument('--test', help='the great and wonderful')
cmd = commands.Command()
cmd.help = 'some text about everything this command does.'
cmd.add_argument('-h', '--help', action='store_true',
help='show this message')
cmd.add_child('bar', child)
expected = ('usage: foo [-h]\n\n'
'some text about everything this command does.\n\n'
'OPTIONS:\n\n'
' -h, --help show this message\n\n'
'COMMANDS:\n\n'
'bar [--test TEST]\n\n'
' some text about this sub-command.\n\n'
' --test TEST the great and wonderful')
self.assertEqual(expected, cmd.format_help('foo').strip())
class RunTest(unittest.TestCase):
def test_default_implmentation_raises_error(self):
with self.assertRaises(NotImplementedError):
commands.Command().run()

View File

@ -105,7 +105,7 @@ class BackendEventsTest(unittest.TestCase):
self.core.tracklist.add([Track(uri='dummy:a')]).get() self.core.tracklist.add([Track(uri='dummy:a')]).get()
send.reset_mock() send.reset_mock()
self.core.tracklist.remove(uri='dummy:a').get() self.core.tracklist.remove(uri=['dummy:a']).get()
self.assertEqual(send.call_args[0][0], 'tracklist_changed') self.assertEqual(send.call_args[0][0], 'tracklist_changed')

View File

@ -37,7 +37,7 @@ class TracklistTest(unittest.TestCase):
self.assertEqual(tl_tracks, self.core.tracklist.tl_tracks[-1:]) self.assertEqual(tl_tracks, self.core.tracklist.tl_tracks[-1:])
def test_remove_removes_tl_tracks_matching_query(self): def test_remove_removes_tl_tracks_matching_query(self):
tl_tracks = self.core.tracklist.remove(name='foo') tl_tracks = self.core.tracklist.remove(name=['foo'])
self.assertEqual(2, len(tl_tracks)) self.assertEqual(2, len(tl_tracks))
self.assertListEqual(self.tl_tracks[:2], tl_tracks) self.assertListEqual(self.tl_tracks[:2], tl_tracks)
@ -46,7 +46,7 @@ class TracklistTest(unittest.TestCase):
self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks)
def test_remove_works_with_dict_instead_of_kwargs(self): def test_remove_works_with_dict_instead_of_kwargs(self):
tl_tracks = self.core.tracklist.remove({'name': 'foo'}) tl_tracks = self.core.tracklist.remove({'name': ['foo']})
self.assertEqual(2, len(tl_tracks)) self.assertEqual(2, len(tl_tracks))
self.assertListEqual(self.tl_tracks[:2], tl_tracks) self.assertListEqual(self.tl_tracks[:2], tl_tracks)
@ -55,15 +55,21 @@ class TracklistTest(unittest.TestCase):
self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks) self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks)
def test_filter_returns_tl_tracks_matching_query(self): def test_filter_returns_tl_tracks_matching_query(self):
tl_tracks = self.core.tracklist.filter(name='foo') tl_tracks = self.core.tracklist.filter(name=['foo'])
self.assertEqual(2, len(tl_tracks)) self.assertEqual(2, len(tl_tracks))
self.assertListEqual(self.tl_tracks[:2], tl_tracks) self.assertListEqual(self.tl_tracks[:2], tl_tracks)
def test_filter_works_with_dict_instead_of_kwargs(self): def test_filter_works_with_dict_instead_of_kwargs(self):
tl_tracks = self.core.tracklist.filter({'name': 'foo'}) tl_tracks = self.core.tracklist.filter({'name': ['foo']})
self.assertEqual(2, len(tl_tracks)) self.assertEqual(2, len(tl_tracks))
self.assertListEqual(self.tl_tracks[:2], tl_tracks) self.assertListEqual(self.tl_tracks[:2], tl_tracks)
def test_filter_fails_if_values_isnt_iterable(self):
self.assertRaises(ValueError, self.core.tracklist.filter, tlid=3)
def test_filter_fails_if_values_is_a_string(self):
self.assertRaises(ValueError, self.core.tracklist.filter, uri='a')
# TODO Extract tracklist tests from the base backend tests # TODO Extract tracklist tests from the base backend tests

View File

@ -11,6 +11,7 @@ key: song8.mp3
file: subdir1/subsubdir/song8.mp3 file: subdir1/subsubdir/song8.mp3
Time: 4 Time: 4
Artist: name Artist: name
AlbumArtist: name
Title: trackname Title: trackname
Album: albumname Album: albumname
Track: 1/2 Track: 1/2
@ -32,6 +33,7 @@ key: song4.mp3
file: subdir1/song4.mp3 file: subdir1/song4.mp3
Time: 4 Time: 4
Artist: name Artist: name
AlbumArtist: name
Title: trackname Title: trackname
Album: albumname Album: albumname
Track: 1/2 Track: 1/2
@ -41,6 +43,7 @@ key: song5.mp3
file: subdir1/song5.mp3 file: subdir1/song5.mp3
Time: 4 Time: 4
Artist: name Artist: name
AlbumArtist: name
Title: trackname Title: trackname
Album: albumname Album: albumname
Track: 1/2 Track: 1/2
@ -76,6 +79,7 @@ key: song1.mp3
file: /song1.mp3 file: /song1.mp3
Time: 4 Time: 4
Artist: name Artist: name
AlbumArtist: name
Title: trackname Title: trackname
Album: albumname Album: albumname
Track: 1/2 Track: 1/2
@ -85,6 +89,7 @@ key: song2.mp3
file: /song2.mp3 file: /song2.mp3
Time: 4 Time: 4
Artist: name Artist: name
AlbumArtist: name
Title: trackname Title: trackname
Album: albumname Album: albumname
Track: 1/2 Track: 1/2

View File

@ -6,6 +6,7 @@ songList begin
key: key1 key: key1
file: /path1 file: /path1
Artist: artist1 Artist: artist1
AlbumArtist: artist1
Title: track1 Title: track1
Album: album1 Album: album1
Date: 2001-02-03 Date: 2001-02-03
@ -14,6 +15,7 @@ Time: 4
key: key2 key: key2
file: /path2 file: /path2
Artist: artist2 Artist: artist2
AlbumArtist: artist2
Title: track2 Title: track2
Album: album2 Album: album2
Date: 2002 Date: 2002
@ -28,4 +30,27 @@ Album: album3
Date: 2003 Date: 2003
Track: 3 Track: 3
Time: 4 Time: 4
key: key4
file: /path4
Artist: artist3
Title: track4
Album: album4
Date: 2004
Track: 4
Comment: This is a fantastic track
Time: 60
key: key5
file: /path5
Composer: artist5
Title: track5
Album: album4
Genre: genre1
Time: 4
key: key6
file: /path6
Performer: artist6
Title: track6
Album: album4
Genre: genre2
Time: 4
songList end songList end

View File

@ -7,6 +7,7 @@ key: song1.mp3
file: /song1.mp3 file: /song1.mp3
Time: 4 Time: 4
Artist: name Artist: name
AlbumArtist: name
Title: trackname Title: trackname
Album: albumname Album: albumname
Track: 1/2 Track: 1/2

View File

@ -7,7 +7,12 @@ key: song1.mp3
file: /song1.mp3 file: /song1.mp3
Time: 4 Time: 4
Artist: æøå Artist: æøå
AlbumArtist: æøå
Composer: æøå
Performer: æøå
Title: æøå Title: æøå
Album: æøå Album: æøå
Genre: æøå
Comment: æøå&^`ൂ㔶
mtime: 1272319626 mtime: 1272319626
songList end songList end

View File

@ -28,6 +28,7 @@ class HttpEventsTest(unittest.TestCase):
'hostname': '127.0.0.1', 'hostname': '127.0.0.1',
'port': 6680, 'port': 6680,
'static_dir': None, 'static_dir': None,
'zeroconf': '',
} }
} }
self.http = actor.HttpFrontend(config=config, core=mock.Mock()) self.http = actor.HttpFrontend(config=config, core=mock.Mock())

View File

@ -1,10 +1,27 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import unittest
from mopidy.frontends.mpd.protocol import music_db
from mopidy.models import Album, Artist, SearchResult, Track from mopidy.models import Album, Artist, SearchResult, Track
from tests.frontends.mpd import protocol from tests.frontends.mpd import protocol
class QueryFromMpdSearchFormatTest(unittest.TestCase):
def test_dates_are_extracted(self):
result = music_db._query_from_mpd_search_format(
'Date "1974-01-02" Date "1975"')
self.assertEqual(result['date'][0], '1974-01-02')
self.assertEqual(result['date'][1], '1975')
# TODO Test more mappings
class QueryFromMpdListFormatTest(unittest.TestCase):
pass # TODO
class MusicDatabaseHandlerTest(protocol.BaseTestCase): class MusicDatabaseHandlerTest(protocol.BaseTestCase):
def test_count(self): def test_count(self):
self.sendRequest('count "artist" "needle"') self.sendRequest('count "artist" "needle"')
@ -261,6 +278,22 @@ class MusicDatabaseFindTest(protocol.BaseTestCase):
self.sendRequest('find albumartist "what"') self.sendRequest('find albumartist "what"')
self.assertInResponse('OK') self.assertInResponse('OK')
def test_find_composer(self):
self.sendRequest('find "composer" "what"')
self.assertInResponse('OK')
def test_find_composer_without_quotes(self):
self.sendRequest('find composer "what"')
self.assertInResponse('OK')
def test_find_performer(self):
self.sendRequest('find "performer" "what"')
self.assertInResponse('OK')
def test_find_performer_without_quotes(self):
self.sendRequest('find performer "what"')
self.assertInResponse('OK')
def test_find_filename(self): def test_find_filename(self):
self.sendRequest('find "filename" "afilename"') self.sendRequest('find "filename" "afilename"')
self.assertInResponse('OK') self.assertInResponse('OK')
@ -297,6 +330,14 @@ class MusicDatabaseFindTest(protocol.BaseTestCase):
self.sendRequest('find "track" ""') self.sendRequest('find "track" ""')
self.assertInResponse('OK') self.assertInResponse('OK')
def test_find_genre(self):
self.sendRequest('find "genre" "what"')
self.assertInResponse('OK')
def test_find_genre_without_quotes(self):
self.sendRequest('find genre "what"')
self.assertInResponse('OK')
def test_find_date(self): def test_find_date(self):
self.sendRequest('find "date" "2002-01-01"') self.sendRequest('find "date" "2002-01-01"')
self.assertInResponse('OK') self.assertInResponse('OK')
@ -456,6 +497,135 @@ class MusicDatabaseListTest(protocol.BaseTestCase):
self.sendRequest('list "albumartist"') self.sendRequest('list "albumartist"')
self.assertNotInResponse('Artist: ') self.assertNotInResponse('Artist: ')
self.assertNotInResponse('Albumartist: ')
self.assertNotInResponse('Composer: ')
self.assertNotInResponse('Performer: ')
self.assertInResponse('OK')
### Composer
def test_list_composer_with_quotes(self):
self.sendRequest('list "composer"')
self.assertInResponse('OK')
def test_list_composer_without_quotes(self):
self.sendRequest('list composer')
self.assertInResponse('OK')
def test_list_composer_without_quotes_and_capitalized(self):
self.sendRequest('list Composer')
self.assertInResponse('OK')
def test_list_composer_with_query_of_one_token(self):
self.sendRequest('list "composer" "anartist"')
self.assertEqualResponse(
'ACK [2@0] {list} should be "Album" for 3 arguments')
def test_list_composer_with_unknown_field_in_query_returns_ack(self):
self.sendRequest('list "composer" "foo" "bar"')
self.assertEqualResponse('ACK [2@0] {list} not able to parse args')
def test_list_composer_by_artist(self):
self.sendRequest('list "composer" "artist" "anartist"')
self.assertInResponse('OK')
def test_list_composer_by_album(self):
self.sendRequest('list "composer" "album" "analbum"')
self.assertInResponse('OK')
def test_list_composer_by_full_date(self):
self.sendRequest('list "composer" "date" "2001-01-01"')
self.assertInResponse('OK')
def test_list_composer_by_year(self):
self.sendRequest('list "composer" "date" "2001"')
self.assertInResponse('OK')
def test_list_composer_by_genre(self):
self.sendRequest('list "composer" "genre" "agenre"')
self.assertInResponse('OK')
def test_list_composer_by_artist_and_album(self):
self.sendRequest(
'list "composer" "artist" "anartist" "album" "analbum"')
self.assertInResponse('OK')
def test_list_composer_without_filter_value(self):
self.sendRequest('list "composer" "artist" ""')
self.assertInResponse('OK')
def test_list_composer_should_not_return_artists_without_names(self):
self.backend.library.dummy_find_exact_result = SearchResult(
tracks=[Track(composers=[Artist(name='')])])
self.sendRequest('list "composer"')
self.assertNotInResponse('Artist: ')
self.assertNotInResponse('Albumartist: ')
self.assertNotInResponse('Composer: ')
self.assertNotInResponse('Performer: ')
self.assertInResponse('OK')
### Performer
def test_list_performer_with_quotes(self):
self.sendRequest('list "performer"')
self.assertInResponse('OK')
def test_list_performer_without_quotes(self):
self.sendRequest('list performer')
self.assertInResponse('OK')
def test_list_performer_without_quotes_and_capitalized(self):
self.sendRequest('list Albumartist')
self.assertInResponse('OK')
def test_list_performer_with_query_of_one_token(self):
self.sendRequest('list "performer" "anartist"')
self.assertEqualResponse(
'ACK [2@0] {list} should be "Album" for 3 arguments')
def test_list_performer_with_unknown_field_in_query_returns_ack(self):
self.sendRequest('list "performer" "foo" "bar"')
self.assertEqualResponse('ACK [2@0] {list} not able to parse args')
def test_list_performer_by_artist(self):
self.sendRequest('list "performer" "artist" "anartist"')
self.assertInResponse('OK')
def test_list_performer_by_album(self):
self.sendRequest('list "performer" "album" "analbum"')
self.assertInResponse('OK')
def test_list_performer_by_full_date(self):
self.sendRequest('list "performer" "date" "2001-01-01"')
self.assertInResponse('OK')
def test_list_performer_by_year(self):
self.sendRequest('list "performer" "date" "2001"')
self.assertInResponse('OK')
def test_list_performer_by_genre(self):
self.sendRequest('list "performer" "genre" "agenre"')
self.assertInResponse('OK')
def test_list_performer_by_artist_and_album(self):
self.sendRequest(
'list "performer" "artist" "anartist" "album" "analbum"')
self.assertInResponse('OK')
def test_list_performer_without_filter_value(self):
self.sendRequest('list "performer" "artist" ""')
self.assertInResponse('OK')
def test_list_performer_should_not_return_artists_without_names(self):
self.backend.library.dummy_find_exact_result = SearchResult(
tracks=[Track(performers=[Artist(name='')])])
self.sendRequest('list "performer"')
self.assertNotInResponse('Artist: ')
self.assertNotInResponse('Albumartist: ')
self.assertNotInResponse('Composer: ')
self.assertNotInResponse('Performer: ')
self.assertInResponse('OK') self.assertInResponse('OK')
### Album ### Album
@ -492,6 +662,14 @@ class MusicDatabaseListTest(protocol.BaseTestCase):
self.sendRequest('list "album" "albumartist" "anartist"') self.sendRequest('list "album" "albumartist" "anartist"')
self.assertInResponse('OK') self.assertInResponse('OK')
def test_list_album_by_composer(self):
self.sendRequest('list "album" "composer" "anartist"')
self.assertInResponse('OK')
def test_list_album_by_performer(self):
self.sendRequest('list "album" "performer" "anartist"')
self.assertInResponse('OK')
def test_list_album_by_full_date(self): def test_list_album_by_full_date(self):
self.sendRequest('list "album" "date" "2001-01-01"') self.sendRequest('list "album" "date" "2001-01-01"')
self.assertInResponse('OK') self.assertInResponse('OK')
@ -679,6 +857,30 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase):
self.sendRequest('search "albumartist" ""') self.sendRequest('search "albumartist" ""')
self.assertInResponse('OK') self.assertInResponse('OK')
def test_search_composer(self):
self.sendRequest('search "composer" "acomposer"')
self.assertInResponse('OK')
def test_search_composer_without_quotes(self):
self.sendRequest('search composer "acomposer"')
self.assertInResponse('OK')
def test_search_composer_without_filter_value(self):
self.sendRequest('search "composer" ""')
self.assertInResponse('OK')
def test_search_performer(self):
self.sendRequest('search "performer" "aperformer"')
self.assertInResponse('OK')
def test_search_performer_without_quotes(self):
self.sendRequest('search performer "aperformer"')
self.assertInResponse('OK')
def test_search_performer_without_filter_value(self):
self.sendRequest('search "performer" ""')
self.assertInResponse('OK')
def test_search_filename(self): def test_search_filename(self):
self.sendRequest('search "filename" "afilename"') self.sendRequest('search "filename" "afilename"')
self.assertInResponse('OK') self.assertInResponse('OK')
@ -739,6 +941,18 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase):
self.sendRequest('search "track" ""') self.sendRequest('search "track" ""')
self.assertInResponse('OK') self.assertInResponse('OK')
def test_search_genre(self):
self.sendRequest('search "genre" "agenre"')
self.assertInResponse('OK')
def test_search_genre_without_quotes(self):
self.sendRequest('search genre "agenre"')
self.assertInResponse('OK')
def test_search_genre_without_filter_value(self):
self.sendRequest('search "genre" ""')
self.assertInResponse('OK')
def test_search_date(self): def test_search_date(self):
self.sendRequest('search "date" "2002-01-01"') self.sendRequest('search "date" "2002-01-01"')
self.assertInResponse('OK') self.assertInResponse('OK')
@ -755,6 +969,18 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase):
self.sendRequest('search "date" ""') self.sendRequest('search "date" ""')
self.assertInResponse('OK') self.assertInResponse('OK')
def test_search_comment(self):
self.sendRequest('search "comment" "acomment"')
self.assertInResponse('OK')
def test_search_comment_without_quotes(self):
self.sendRequest('search comment "acomment"')
self.assertInResponse('OK')
def test_search_comment_without_filter_value(self):
self.sendRequest('search "comment" ""')
self.assertInResponse('OK')
def test_search_else_should_fail(self): def test_search_else_should_fail(self):
self.sendRequest('search "sometype" "something"') self.sendRequest('search "sometype" "something"')
self.assertEqualResponse('ACK [2@0] {search} incorrect arguments') self.assertEqualResponse('ACK [2@0] {search} incorrect arguments')

View File

@ -21,7 +21,7 @@ class StatusHandlerTest(protocol.BaseTestCase):
self.assertInResponse('Title: ') self.assertInResponse('Title: ')
self.assertInResponse('Album: ') self.assertInResponse('Album: ')
self.assertInResponse('Track: 0') self.assertInResponse('Track: 0')
self.assertInResponse('Date: ') self.assertNotInResponse('Date: ')
self.assertInResponse('Pos: 0') self.assertInResponse('Pos: 0')
self.assertInResponse('Id: 0') self.assertInResponse('Id: 0')
self.assertInResponse('OK') self.assertInResponse('OK')

View File

@ -17,7 +17,12 @@ class TrackMpdFormatTest(unittest.TestCase):
album=Album(name='an album', num_tracks=13, album=Album(name='an album', num_tracks=13,
artists=[Artist(name='an other artist')]), artists=[Artist(name='an other artist')]),
track_no=7, track_no=7,
composers=[Artist(name='a composer')],
performers=[Artist(name='a performer')],
genre='a genre',
date=datetime.date(1977, 1, 1), date=datetime.date(1977, 1, 1),
disc_no='1',
comment='a comment',
length=137000, length=137000,
) )
@ -36,8 +41,8 @@ class TrackMpdFormatTest(unittest.TestCase):
self.assertIn(('Title', ''), result) self.assertIn(('Title', ''), result)
self.assertIn(('Album', ''), result) self.assertIn(('Album', ''), result)
self.assertIn(('Track', 0), result) self.assertIn(('Track', 0), result)
self.assertIn(('Date', ''), result) self.assertNotIn(('Date', ''), result)
self.assertEqual(len(result), 7) self.assertEqual(len(result), 6)
def test_track_to_mpd_format_with_position(self): def test_track_to_mpd_format_with_position(self):
result = translator.track_to_mpd_format(Track(), position=1) result = translator.track_to_mpd_format(Track(), position=1)
@ -62,11 +67,16 @@ class TrackMpdFormatTest(unittest.TestCase):
self.assertIn(('Title', 'a name'), result) self.assertIn(('Title', 'a name'), result)
self.assertIn(('Album', 'an album'), result) self.assertIn(('Album', 'an album'), result)
self.assertIn(('AlbumArtist', 'an other artist'), result) self.assertIn(('AlbumArtist', 'an other artist'), result)
self.assertIn(('Composer', 'a composer'), result)
self.assertIn(('Performer', 'a performer'), result)
self.assertIn(('Genre', 'a genre'), result)
self.assertIn(('Track', '7/13'), result) self.assertIn(('Track', '7/13'), result)
self.assertIn(('Date', datetime.date(1977, 1, 1)), result) self.assertIn(('Date', datetime.date(1977, 1, 1)), result)
self.assertIn(('Disc', '1'), result)
self.assertIn(('Comment', 'a comment'), result)
self.assertIn(('Pos', 9), result) self.assertIn(('Pos', 9), result)
self.assertIn(('Id', 122), result) self.assertIn(('Id', 122), result)
self.assertEqual(len(result), 10) self.assertEqual(len(result), 15)
def test_track_to_mpd_format_musicbrainz_trackid(self): def test_track_to_mpd_format_musicbrainz_trackid(self):
track = self.track.copy(musicbrainz_id='foo') track = self.track.copy(musicbrainz_id='foo')
@ -118,20 +128,6 @@ class PlaylistMpdFormatTest(unittest.TestCase):
self.assertEqual(dict(result[0])['Track'], 2) self.assertEqual(dict(result[0])['Track'], 2)
class QueryFromMpdSearchFormatTest(unittest.TestCase):
def test_dates_are_extracted(self):
result = translator.query_from_mpd_search_format(
'Date "1974-01-02" Date "1975"')
self.assertEqual(result['date'][0], '1974-01-02')
self.assertEqual(result['date'][1], '1975')
# TODO Test more mappings
class QueryFromMpdListFormatTest(unittest.TestCase):
pass # TODO
class TracksToTagCacheFormatTest(unittest.TestCase): class TracksToTagCacheFormatTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.media_dir = '/dir/subdir' self.media_dir = '/dir/subdir'

View File

@ -22,6 +22,5 @@ class HelpTest(unittest.TestCase):
self.assertIn('--quiet', output) self.assertIn('--quiet', output)
self.assertIn('--verbose', output) self.assertIn('--verbose', output)
self.assertIn('--save-debug-log', output) self.assertIn('--save-debug-log', output)
self.assertIn('--show-config', output)
self.assertIn('--config', output) self.assertIn('--config', output)
self.assertIn('--option', output) self.assertIn('--option', output)

View File

@ -450,12 +450,14 @@ class TrackTest(unittest.TestCase):
def test_repr_without_artists(self): def test_repr_without_artists(self):
self.assertEquals( self.assertEquals(
"Track(artists=[], name=u'name', uri=u'uri')", "Track(artists=[], composers=[], name=u'name', "
"performers=[], uri=u'uri')",
repr(Track(uri='uri', name='name'))) repr(Track(uri='uri', name='name')))
def test_repr_with_artists(self): def test_repr_with_artists(self):
self.assertEquals( self.assertEquals(
"Track(artists=[Artist(name=u'foo')], name=u'name', uri=u'uri')", "Track(artists=[Artist(name=u'foo')], composers=[], name=u'name', "
"performers=[], uri=u'uri')",
repr(Track(uri='uri', name='name', artists=[Artist(name='foo')]))) repr(Track(uri='uri', name='name', artists=[Artist(name='foo')])))
def test_serialize_without_artists(self): def test_serialize_without_artists(self):
@ -670,7 +672,8 @@ class TlTrackTest(unittest.TestCase):
def test_repr(self): def test_repr(self):
self.assertEquals( self.assertEquals(
"TlTrack(tlid=123, track=Track(artists=[], uri=u'uri'))", "TlTrack(tlid=123, track=Track(artists=[], composers=[], "
"performers=[], uri=u'uri'))",
repr(TlTrack(tlid=123, track=Track(uri='uri')))) repr(TlTrack(tlid=123, track=Track(uri='uri'))))
def test_serialize(self): def test_serialize(self):
@ -804,8 +807,8 @@ class PlaylistTest(unittest.TestCase):
def test_repr_with_tracks(self): def test_repr_with_tracks(self):
self.assertEquals( self.assertEquals(
"Playlist(name=u'name', tracks=[Track(artists=[], name=u'foo')], " "Playlist(name=u'name', tracks=[Track(artists=[], composers=[], "
"uri=u'uri')", "name=u'foo', performers=[])], uri=u'uri')",
repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')]))) repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')])))
def test_serialize_without_tracks(self): def test_serialize_without_tracks(self):

View File

@ -102,17 +102,29 @@ class GetOrCreateFileTest(unittest.TestCase):
def test_create_file_with_name_of_existing_dir_throws_ioerror(self): def test_create_file_with_name_of_existing_dir_throws_ioerror(self):
conflicting_dir = os.path.join(self.parent) conflicting_dir = os.path.join(self.parent)
self.assertRaises(IOError, path.get_or_create_file, conflicting_dir) with self.assertRaises(IOError):
path.get_or_create_file(conflicting_dir)
def test_create_dir_with_unicode(self): def test_create_dir_with_unicode(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
file_path = unicode(os.path.join(self.parent, b'test')) file_path = unicode(os.path.join(self.parent, b'test'))
path.get_or_create_file(file_path) path.get_or_create_file(file_path)
def test_create_dir_with_none(self): def test_create_file_with_none(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
path.get_or_create_file(None) path.get_or_create_file(None)
def test_create_dir_without_mkdir(self):
file_path = os.path.join(self.parent, b'foo', b'bar')
with self.assertRaises(IOError):
path.get_or_create_file(file_path, mkdir=False)
def test_create_dir_with_default_content(self):
file_path = os.path.join(self.parent, b'test')
created = path.get_or_create_file(file_path, content=b'foobar')
with open(created) as fh:
self.assertEqual(fh.read(), b'foobar')
class PathToFileURITest(unittest.TestCase): class PathToFileURITest(unittest.TestCase):
def test_simple_path(self): def test_simple_path(self):