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>
- Javier Domingo <javierdo1@gmail.com>
- Lasse Bigum <lasse@bigum.org>
- David Eisner <david.eisner@oriel.oxon.org>

View File

@ -28,3 +28,10 @@ Audio listener
.. autoclass:: mopidy.audio.AudioListener
: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
audio
frontends
commands
ext
config
http

View File

@ -5,6 +5,126 @@ Changelog
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)
====================
@ -25,7 +145,7 @@ in Debian.
**MPD frontend**
- 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)

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
available for use.
The ``mopidy-convert-config`` command is used to convert ``settings.py``
configuration files used by ``mopidy`` < 0.14 to the ``mopidy.conf`` config
The ``mopidy-convert-config`` command is used to convert :file:`settings.py`
configuration files used by ``mopidy`` < 0.14 to the :file:`mopidy.conf` config
file used by ``mopidy`` >= 0.14.
@ -30,16 +30,16 @@ Options
.. program:: mopidy-convert-config
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
ouputs a Mopidy 0.14 compatible ini-format configuration. If you don't already
have a config file at ``$XDG_CONFIG_DIR/mopidy/mopidy.conf``, you're asked if
you want to save the converted config to that file.
at :file:`{$XDG_CONFIG_DIR}/mopidy/settings.py`, and if it exists it converts
it and ouputs a Mopidy 0.14 compatible ini-format configuration. If you don't
already have a config file at :file:`{$XDG_CONFIG_DIR}/mopidy/mopidy.conf``,
you're asked if you want to save the converted config to that file.
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'
Running ``mopidy-convert-config`` will convert the config and create a new
``mopidy.conf`` config file:
:file:`mopidy.conf` config file:
.. 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
Done.
Contents of ``~/.config/mopidy/mopidy.conf`` after the conversion:
Contents of :file:`~/.config/mopidy/mopidy.conf` after the conversion:
.. 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
[-h] [--version] [-q] [-v] [--save-debug-log] [--show-config]
[--show-deps] [--config CONFIG_FILES] [-o CONFIG_OVERRIDES]
[-h] [--version] [-q] [-v] [--save-debug-log] [--config CONFIG_FILES]
[-o CONFIG_OVERRIDES] [COMMAND] ...
Description
@ -29,7 +29,7 @@ Options
.. program:: mopidy
.. cmdoption:: -h, --help
.. cmdoption:: --help, -h
Show help message and exit.
@ -37,11 +37,11 @@ Options
Show Mopidy's version number and exit.
.. cmdoption:: -q, --quiet
.. cmdoption:: --quiet, -q
Show less output: warning level and higher.
.. cmdoption:: -v, --verbose
.. cmdoption:: --verbose, -v
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`
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>
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.
.. cmdoption:: -o <option>, --option <option>
.. cmdoption:: --option <option>, -o <option>
Specify additional config values in the ``section/key=value`` format. Can
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
=====
/etc/mopidy/mopidy.conf
:file:`/etc/mopidy/mopidy.conf`
System wide Mopidy configuration file.
~/.config/mopidy/mopidy.conf
:file:`~/.config/mopidy/mopidy.conf`
Your personal Mopidy configuration file. Overrides any configs from the
system wide configuration file.
@ -105,17 +121,16 @@ configs::
mopidy -o mpd/enabled=false -o spotify/bitrate=320
The :option:`--show-config` output shows the effect of the :option:`--option`
flags::
The ``mopidy config`` output shows the effect of the :option:`--option` flags::
mopidy -o mpd/enabled=false -o spotify/bitrate=320 --show-config
mopidy -o mpd/enabled=false -o spotify/bitrate=320 config
See also
========
:ref:`mopidy-scan(1) <mopidy-scan-cmd>`, :ref:`mopidy-convert-config(1)
<mopidy-convert-config>`
:ref:`mopidy-convert-config(1) <mopidy-convert-config>`
Reporting bugs
==============

View File

@ -8,11 +8,6 @@ import os
import sys
# -- Read The Docs configuration ----------------------------------------------
RTD_NEW_THEME = True
# -- Workarounds to have autodoc generate API docs ----------------------------
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
@ -146,13 +141,6 @@ man_pages = [
'',
'1'
),
(
'commands/mopidy-scan',
'mopidy-scan',
'index music for playback with mopidy',
'',
'1'
),
(
'commands/mopidy-convert-config',
'mopidy-convert-config',
@ -165,4 +153,8 @@ man_pages = [
# -- 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
few to get up and running. A complete ``~/.config/mopidy/mopidy.conf`` may be
as simple as this:
few to get up and running. A complete :file:`~/.config/mopidy/mopidy.conf` may
be as simple as this:
.. code-block:: ini
@ -15,17 +15,18 @@ as simple as this:
username = alice
password = mysecret
Mopidy primarily reads config from the file ``~/.config/mopidy/mopidy.conf``,
where ``~`` means your *home directory*. If your username is ``alice`` and you
are running Linux, the config file should probably be at
``/home/alice/.config/mopidy/mopidy.conf``. You can either create the
configuration file yourself, or run the ``mopidy`` command, and it will create
an empty config file for you and print what config values must be set to
successfully start Mopidy.
Mopidy primarily reads config from the file
:file:`~/.config/mopidy/mopidy.conf`, where ``~`` means your *home directory*.
If your username is ``alice`` and you are running Linux, the config file should
probably be at :file:`/home/alice/.config/mopidy/mopidy.conf`. You can either
create the configuration file yourself, or run the ``mopidy`` command, and it
will create an empty config file for you and print what config values must be
set to successfully start Mopidy.
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
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
run :option:`mopidy --show-config`. It will print your full effective config
@ -60,17 +61,23 @@ Core configuration values
Audio mixer to use.
Expects a GStreamer mixer to use, typical values are: ``autoaudiomixer``,
``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``.
Expects a GStreamer mixer to use, typical values are: ``software``,
``autoaudiomixer``, ``alsamixer``, ``pulsemixer``, ``ossmixer``, and
``oss4mixer``.
The default is ``autoaudiomixer``, which attempts to select a sane mixer
for you automatically. When Mopidy is started, it will log what mixer
``autoaudiomixer`` selected, for example::
The default is ``software``, which does volume control inside Mopidy before
the audio is sent to the audio output. This mixer does not affect the
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"
Setting the config value to blank turns off volume control. ``software``
can be used to force software mixing in the application.
Setting the config value to blank turns off volume control.
.. 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.
"/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
=====

View File

@ -6,7 +6,7 @@ Mopidy-Local
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
@ -71,7 +71,7 @@ music...
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
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
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
standard output, which means that you will need to redirect the output to a
file yourself::
#. Scan your media library. The command writes the ``tag_cache`` to
the :confval:`local/tag_cache_file`::
mopidy-scan > tag_cache
#. 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.
mopidy local scan
#. 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
- Crossfade is not supported
- Replay gain is not supported
- ``count`` 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
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
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
=====

View File

@ -305,6 +305,10 @@ This is ``mopidy_soundspot/__init__.py``::
from .backend import SoundspotBackend
return [SoundspotBackend]
def get_command(self):
from .commands import SoundspotCommand
return SoundspotCommand()
def register_gstreamer_elements(self):
from .mixer import 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
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
details.
@ -373,6 +378,34 @@ details.
# 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
=========================

View File

@ -7,7 +7,7 @@ To start Mopidy, simply open a terminal and run::
mopidy
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
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
=======================
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
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
@ -38,7 +38,7 @@ with others for debugging.
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
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

View File

@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring)
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
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
@ -18,78 +26,119 @@ sys.argv[1:] = []
from mopidy import commands, ext
from mopidy.audio import Audio
from mopidy import config as config_lib
from mopidy.core import Core
from mopidy.utils import log, path, process
from mopidy.utils import log, path, process, versioning
logger = logging.getLogger('mopidy.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.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:
# Initial config without extensions to bootstrap logging.
logging_initialized = False
logging_config, _ = config_lib.load(
args.config_files, [], args.config_overrides)
root_cmd = commands.RootCommand()
config_cmd = commands.ConfigCommand()
deps_cmd = commands.DepsCommand()
# TODO: setup_logging needs defaults in-case config values are None
log.setup_logging(
logging_config, args.verbosity_level, args.save_debug_log)
logging_initialized = True
create_file_structures()
check_old_locations()
root_cmd.set(extension=None)
root_cmd.add_child('config', config_cmd)
root_cmd.add_child('deps', deps_cmd)
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(
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 = []
for extension in installed_extensions:
enabled = config[extension.ext_name]['enabled']
if ext.validate_extension(extension) and enabled:
if not ext.validate_extension(extension):
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)
elif extension.ext_name in config_errors:
del config_errors[extension.ext_name]
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)
# Read-only config from here on, please.
proxied_config = config_lib.Proxy(config)
log.setup_log_levels(proxied_config)
ext.register_gstreamer_elements(enabled_extensions)
if args.extension and args.extension not in 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
# mopidy.utils.process.exit_process as actors have been started.
start(proxied_config, enabled_extensions)
# mopidy.utils.process.exit_process as actors can have been started.
try:
return args.command.run(args, proxied_config, enabled_extensions)
except NotImplementedError:
print root_cmd.format_help()
return 1
except KeyboardInterrupt:
pass
except Exception as ex:
if logging_initialized:
logger.exception(ex)
logger.exception(ex)
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_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():
@ -127,89 +176,5 @@ def check_config_errors(errors):
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__':
main()
sys.exit(main())

View File

@ -1,9 +1,7 @@
"""Mixer element that automatically selects the real mixer to use.
This is Mopidy's default mixer.
If this wasn't the default, you would set the :confval:`audio/mixer` config
value to ``autoaudiomixer`` to use this mixer.
Set the :confval:`audio/mixer` config value to ``autoaudiomixer`` to use this
mixer.
"""
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):
from .library import 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
scan_timeout = 1000
excluded_file_extensions =
.html
.jpeg
.jpg
.log
.nfo
.png
.txt
.log

View File

@ -8,7 +8,7 @@ from mopidy.backends import base
from mopidy.frontends.mpd import translator as mpd_translator
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')
@ -72,37 +72,58 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
q = value.strip()
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
artist_filter = lambda t: filter(
lambda a: q == a.name, t.artists)
albumartist_filter = lambda t: any([
q == a.name
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
genre_filter = lambda t: t.genre and q == t.genre
date_filter = lambda t: q == t.date
comment_filter = lambda t: q == t.comment
any_filter = lambda t: (
uri_filter(t) or
track_filter(t) or
track_name_filter(t) or
album_filter(t) or
artist_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':
result_tracks = filter(uri_filter, result_tracks)
elif field == 'track':
result_tracks = filter(track_filter, result_tracks)
elif field == 'track_name':
result_tracks = filter(track_name_filter, result_tracks)
elif field == 'album':
result_tracks = filter(album_filter, result_tracks)
elif field == 'artist':
result_tracks = filter(artist_filter, result_tracks)
elif field == 'albumartist':
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':
result_tracks = filter(track_no_filter, result_tracks)
elif field == 'genre':
result_tracks = filter(genre_filter, result_tracks)
elif field == 'date':
result_tracks = filter(date_filter, result_tracks)
elif field == 'comment':
result_tracks = filter(comment_filter, result_tracks)
elif field == 'any':
result_tracks = filter(any_filter, result_tracks)
else:
@ -129,7 +150,7 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
q = value.strip().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(
t, 'album', Album()).name.lower()
artist_filter = lambda t: filter(
@ -137,30 +158,51 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
albumartist_filter = lambda t: any([
q in a.name.lower()
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
genre_filter = lambda t: t.genre and q in t.genre.lower()
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: (
uri_filter(t) or
track_filter(t) or
track_name_filter(t) or
album_filter(t) or
artist_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':
result_tracks = filter(uri_filter, result_tracks)
elif field == 'track':
result_tracks = filter(track_filter, result_tracks)
elif field == 'track_name':
result_tracks = filter(track_name_filter, result_tracks)
elif field == 'album':
result_tracks = filter(album_filter, result_tracks)
elif field == 'artist':
result_tracks = filter(artist_filter, result_tracks)
elif field == 'albumartist':
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':
result_tracks = filter(track_no_filter, result_tracks)
elif field == 'genre':
result_tracks = filter(genre_filter, result_tracks)
elif field == 'date':
result_tracks = filter(date_filter, result_tracks)
elif field == 'comment':
result_tracks = filter(comment_filter, result_tracks)
elif field == 'any':
result_tracks = filter(any_filter, result_tracks)
else:
@ -189,7 +231,10 @@ class LocalLibraryUpdateProvider(base.BaseLibraryProvider):
def load(self):
tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir)
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
def add(self, track):

View File

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

View File

@ -6,11 +6,18 @@ import urlparse
from mopidy.models import Track, Artist, Album
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')
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):
r"""
Convert M3U file list of uris
@ -120,20 +127,31 @@ def _convert_mpd_data(data, tracks):
if 'artist' in data:
artist_kwargs['name'] = data['artist']
albumartist_kwargs['name'] = data['artist']
if 'albumartist' in data:
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:
album_kwargs['name'] = data['album']
if 'title' in data:
track_kwargs['name'] = data['title']
if 'genre' in data:
track_kwargs['genre'] = data['genre']
if 'date' in data:
track_kwargs['date'] = data['date']
if 'comment' in data:
track_kwargs['comment'] = data['comment']
if 'musicbrainz_trackid' in data:
track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid']

View File

@ -1,10 +1,19 @@
from __future__ import unicode_literals
import argparse
import collections
import logging
import os
import sys
from mopidy import config as config_lib, ext
from mopidy.utils import deps, versioning
import gobject
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):
@ -21,63 +30,322 @@ def config_override_type(value):
'%s must have the format section/key=value' % value)
parser = argparse.ArgumentParser()
parser.add_argument(
'--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')
class _ParserError(Exception):
pass
def show_config(args):
"""Prints the effective config and exits."""
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)
class _HelpError(Exception):
pass
def show_deps():
"""Prints a list of all dependencies and exits."""
print deps.format_dependency_list()
sys.exit(0)
class _ArgumentParser(argparse.ArgumentParser):
def error(self, message):
raise _ParserError(message)
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.schemas import * # noqa
from mopidy.config.types import * # noqa
from mopidy.utils import path
from mopidy.utils import path, versioning
logger = logging.getLogger('mopidy.config')
@ -41,6 +41,18 @@ _proxy_schema['password'] = Secret(optional=True)
_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):
"""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.
schemas = _schemas[:]
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):
@ -128,7 +158,7 @@ def _validate(raw_config, schemas):
return config, errors
def _format(config, comments, schemas, display):
def _format(config, comments, schemas, display, disable):
output = []
for schema in schemas:
serialized = schema.serialize(
@ -142,9 +172,11 @@ def _format(config, comments, schemas, display):
if value is not None:
output[-1] += b' ' + value
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'')
return b'\n'.join(output)
return b'\n'.join(output).strip()
def _preprocess(config_string):

View File

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

View File

@ -1,5 +1,6 @@
from __future__ import unicode_literals
import collections
import logging
import random
@ -292,36 +293,51 @@ class TracklistController(object):
"""
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::
# Returns track with TLID 7 (tracklist ID)
filter({'tlid': 7})
filter(tlid=7)
# Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID)
filter({'tlid': [1, 2, 3, 4]})
filter(tlid=[1, 2, 3, 4])
# Returns track with ID 1
filter({'id': 1})
filter(id=1)
# Returns track with IDs 1, 5, or 7
filter({'id': [1, 5, 7]})
filter(id=[1, 5, 7])
# Returns track with URI 'xyz'
filter({'uri': 'xyz'})
filter(uri='xyz')
# Returns track with URIs 'xyz' or 'abc'
filter({'uri': ['xyz', 'abc']})
filter(uri=['xyz', 'abc'])
# Returns track with ID 1 and URI 'xyz'
filter({'id': 1, 'uri': 'xyz'})
filter(id=1, uri='xyz')
# Returns tracks with ID 1 and 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
:type criteria: dict
:type criteria: dict, of (string, list) pairs
:rtype: list of :class:`mopidy.models.TlTrack`
"""
criteria = criteria or kwargs
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':
matches = filter(lambda ct: ct.tlid == value, matches)
matches = filter(lambda ct: ct.tlid in values, matches)
else:
matches = filter(
lambda ct: getattr(ct.track, key) == value, matches)
lambda ct: getattr(ct.track, key) in values, matches)
return matches
def move(self, start, end, to_position):
@ -435,7 +451,7 @@ class TracklistController(object):
"""Private method used by :class:`mopidy.core.PlaybackController`."""
if not self.consume:
return False
self.remove(tlid=tl_track.tlid)
self.remove(tlid=[tl_track.tlid])
return True
def _trigger_tracklist_changed(self):

View File

@ -87,6 +87,14 @@ class Extension(object):
"""
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):
"""Hook for registering custom GStreamer elements

View File

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

View File

@ -11,6 +11,7 @@ from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool
from mopidy import models
from mopidy.core import CoreListener
from mopidy.utils import zeroconf
from . import ws
@ -22,6 +23,12 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
super(HttpFrontend, self).__init__()
self.config = config
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_websocket_plugin()
app = self._create_app()
@ -30,8 +37,8 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
def _setup_server(self):
cherrypy.config.update({
'engine.autoreload_on': False,
'server.socket_host': self.config['http']['hostname'],
'server.socket_port': self.config['http']['port'],
'server.socket_host': self.hostname,
'server.socket_port': self.port,
})
def _setup_websocket_plugin(self):
@ -88,7 +95,21 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
cherrypy.engine.start()
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):
if self.zeroconf_service:
self.zeroconf_service.unpublish()
logger.debug('Stopping HTTP server')
cherrypy.engine.exit()
logger.info('Stopped HTTP server')

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import pykka
from mopidy.core import CoreListener
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')
@ -15,12 +15,16 @@ logger = logging.getLogger('mopidy.frontends.mpd')
class MpdFrontend(pykka.ThreadingActor, CoreListener):
def __init__(self, config, core):
super(MpdFrontend, self).__init__()
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:
network.Server(
hostname, port,
self.hostname, self.port,
protocol=session.MpdSession,
protocol_kwargs={
'config': config,
@ -34,9 +38,24 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
encoding.locale_decode(error))
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):
if self.zeroconf_service:
self.zeroconf_service.unpublish()
process.stop_actors_by_class(session.MpdSession)
def send_idle(self, subsystem):

View File

@ -5,3 +5,4 @@ port = 6600
password =
max_connections = 20
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
be ``this thing``::
@handle_request('^do (?P<what>.+)$')
@handle_request('do\ (?P<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
:type pattern: string
"""
@ -56,7 +61,7 @@ def handle_request(pattern, auth_required=True):
if match is not None:
mpd_commands.add(
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:
raise ValueError('Tried to redefine handler for %s with %s' % (
pattern, func))

View File

@ -4,7 +4,7 @@ from mopidy.frontends.mpd.exceptions import MpdNoExistError
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):
"""
*musicpd.org, audio output section:*
@ -19,7 +19,7 @@ def disableoutput(context, outputid):
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):
"""
*musicpd.org, audio output section:*
@ -34,7 +34,7 @@ def enableoutput(context, outputid):
raise MpdNoExistError('No such audio output', command='enableoutput')
@handle_request(r'^outputs$')
@handle_request(r'outputs$')
def outputs(context):
"""
*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
@handle_request(r'^subscribe "(?P<channel>[A-Za-z0-9:._-]+)"$')
@handle_request(r'subscribe\ "(?P<channel>[A-Za-z0-9:._-]+)"$')
def subscribe(context, channel):
"""
*musicpd.org, client to client section:*
@ -18,7 +18,7 @@ def subscribe(context, channel):
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):
"""
*musicpd.org, client to client section:*
@ -30,7 +30,7 @@ def unsubscribe(context, channel):
raise MpdNotImplemented # TODO
@handle_request(r'^channels$')
@handle_request(r'channels$')
def channels(context):
"""
*musicpd.org, client to client section:*
@ -43,7 +43,7 @@ def channels(context):
raise MpdNotImplemented # TODO
@handle_request(r'^readmessages$')
@handle_request(r'readmessages$')
def readmessages(context):
"""
*musicpd.org, client to client section:*
@ -57,7 +57,7 @@ def readmessages(context):
@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):
"""
*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
@handle_request(r'^command_list_begin$')
@handle_request(r'command_list_begin$')
def command_list_begin(context):
"""
*musicpd.org, command list section:*
@ -26,7 +26,7 @@ def command_list_begin(context):
context.dispatcher.command_list = []
@handle_request(r'^command_list_end$')
@handle_request(r'command_list_end$')
def command_list_end(context):
"""See :meth:`command_list_begin()`."""
if not context.dispatcher.command_list_receiving:
@ -49,7 +49,7 @@ def command_list_end(context):
return command_list_response
@handle_request(r'^command_list_ok_begin$')
@handle_request(r'command_list_ok_begin$')
def command_list_ok_begin(context):
"""See :meth:`command_list_begin()`."""
context.dispatcher.command_list_receiving = True

View File

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

View File

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

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
import functools
import itertools
import re
from mopidy.models import Track
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
QUERY_RE = (
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Aa]lbumartist|[Dd]ate|[Ff]ile|'
r'[Ff]ilename|[Tt]itle|[Tt]rack|[Aa]ny)"? "[^"]*"\s?)+)$')
LIST_QUERY = r"""
("?) # Optional quote around the field type
(?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):
@ -39,7 +145,7 @@ def _artist_as_track(artist):
artists=[artist])
@handle_request(r'^count ' + QUERY_RE)
@handle_request(r'count\ ' + SEARCH_QUERY)
def count(context, mpd_query):
"""
*musicpd.org, music database section:*
@ -55,7 +161,7 @@ def count(context, mpd_query):
- use multiple tag-needle pairs to make more specific searches.
"""
try:
query = translator.query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_format(mpd_query)
except ValueError:
raise MpdArgError('incorrect arguments', command='count')
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):
"""
*musicpd.org, music database section:*
@ -95,12 +201,15 @@ def find(context, mpd_query):
- uses "file" instead of "filename".
"""
try:
query = translator.query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_format(mpd_query)
except ValueError:
return
results = context.core.library.find_exact(**query).get()
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)]
if 'album' not in query:
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)
@handle_request(r'^findadd ' + QUERY_RE)
@handle_request(r'findadd\ ' + SEARCH_QUERY)
def findadd(context, mpd_query):
"""
*musicpd.org, music database section:*
@ -119,16 +228,14 @@ def findadd(context, mpd_query):
current playlist. Parameters have the same meaning as for ``find``.
"""
try:
query = translator.query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_format(mpd_query)
except ValueError:
return
results = context.core.library.find_exact(**query).get()
context.core.tracklist.add(_get_tracks(results))
@handle_request(
r'^list "?(?P<field>([Aa]rtist|[Aa]lbumartist|[Aa]lbum|[Dd]ate|'
r'[Gg]enre))"?( (?P<mpd_query>.*))?$')
@handle_request(r'list\ ' + LIST_QUERY)
def list_(context, field, mpd_query=None):
"""
*musicpd.org, music database section:*
@ -222,10 +329,14 @@ def list_(context, field, mpd_query=None):
return _list_albumartist(context, query)
elif field == 'album':
return _list_album(context, query)
elif field == 'composer':
return _list_composer(context, query)
elif field == 'performer':
return _list_performer(context, query)
elif field == 'date':
return _list_date(context, query)
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):
@ -258,6 +369,26 @@ def _list_album(context, query):
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):
dates = set()
results = context.core.library.find_exact(**query).get()
@ -267,8 +398,17 @@ def _list_date(context, query):
return dates
@handle_request(r'^listall$')
@handle_request(r'^listall "(?P<uri>[^"]+)"$')
def _list_genre(context, query):
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):
"""
*musicpd.org, music database section:*
@ -280,8 +420,8 @@ def listall(context, uri=None):
raise MpdNotImplemented # TODO
@handle_request(r'^listallinfo$')
@handle_request(r'^listallinfo "(?P<uri>[^"]+)"$')
@handle_request(r'listallinfo$')
@handle_request(r'listallinfo\ "(?P<uri>[^"]+)"$')
def listallinfo(context, uri=None):
"""
*musicpd.org, music database section:*
@ -294,8 +434,8 @@ def listallinfo(context, uri=None):
raise MpdNotImplemented # TODO
@handle_request(r'^lsinfo$')
@handle_request(r'^lsinfo "(?P<uri>[^"]*)"$')
@handle_request(r'lsinfo$')
@handle_request(r'lsinfo\ "(?P<uri>[^"]*)"$')
def lsinfo(context, uri=None):
"""
*musicpd.org, music database section:*
@ -317,7 +457,8 @@ def lsinfo(context, uri=None):
raise MpdNotImplemented # TODO
@handle_request(r'^rescan( "(?P<uri>[^"]+)")*$')
@handle_request(r'rescan$')
@handle_request(r'rescan\ "(?P<uri>[^"]+)"$')
def rescan(context, uri=None):
"""
*musicpd.org, music database section:*
@ -329,7 +470,7 @@ def rescan(context, uri=None):
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):
"""
*musicpd.org, music database section:*
@ -358,7 +499,7 @@ def search(context, mpd_query):
- uses "file" instead of "filename".
"""
try:
query = translator.query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_format(mpd_query)
except ValueError:
return
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)
@handle_request(r'^searchadd ' + QUERY_RE)
@handle_request(r'searchadd\ ' + SEARCH_QUERY)
def searchadd(context, mpd_query):
"""
*musicpd.org, music database section:*
@ -382,14 +523,14 @@ def searchadd(context, mpd_query):
not case sensitive.
"""
try:
query = translator.query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_format(mpd_query)
except ValueError:
return
results = context.core.library.search(**query).get()
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):
"""
*musicpd.org, music database section:*
@ -405,7 +546,7 @@ def searchaddpl(context, playlist_name, mpd_query):
not case sensitive.
"""
try:
query = translator.query_from_mpd_search_format(mpd_query)
query = _query_from_mpd_search_format(mpd_query)
except ValueError:
return
results = context.core.library.search(**query).get()
@ -418,7 +559,8 @@ def searchaddpl(context, playlist_name, mpd_query):
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):
"""
*musicpd.org, music database section:*

View File

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

View File

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

View File

@ -5,8 +5,8 @@ from mopidy.frontends.mpd.exceptions import MpdNotImplemented
@handle_request(
r'^sticker delete "(?P<field>[^"]+)" '
r'"(?P<uri>[^"]+)"( "(?P<name>[^"]+)")*$')
r'sticker\ delete\ "(?P<field>[^"]+)"\ '
r'"(?P<uri>[^"]+)"(\ "(?P<name>[^"]+)")*$')
def sticker_delete(context, field, uri, name=None):
"""
*musicpd.org, sticker section:*
@ -20,7 +20,7 @@ def sticker_delete(context, field, uri, name=None):
@handle_request(
r'^sticker find "(?P<field>[^"]+)" "(?P<uri>[^"]+)" '
r'sticker\ find\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
r'"(?P<name>[^"]+)"$')
def sticker_find(context, field, uri, name):
"""
@ -36,7 +36,7 @@ def sticker_find(context, field, uri, name):
@handle_request(
r'^sticker get "(?P<field>[^"]+)" "(?P<uri>[^"]+)" '
r'sticker\ get\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
r'"(?P<name>[^"]+)"$')
def sticker_get(context, field, uri, name):
"""
@ -49,7 +49,7 @@ def sticker_get(context, field, uri, name):
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):
"""
*musicpd.org, sticker section:*
@ -62,8 +62,8 @@ def sticker_list(context, field, uri):
@handle_request(
r'^sticker set "(?P<field>[^"]+)" "(?P<uri>[^"]+)" '
r'"(?P<name>[^"]+)" "(?P<value>[^"]+)"$')
r'sticker\ set\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
r'"(?P<name>[^"]+)"\ "(?P<value>[^"]+)"$')
def sticker_set(context, field, uri, name, value):
"""
*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
@handle_request(r'^listplaylist (?P<name>\w+)$')
@handle_request(r'^listplaylist "(?P<name>[^"]+)"$')
@handle_request(r'listplaylist\ ("?)(?P<name>[^"]+)\1$')
def listplaylist(context, name):
"""
*musicpd.org, stored playlists section:*
@ -29,8 +28,7 @@ def listplaylist(context, name):
return ['file: %s' % t.uri for t in playlist.tracks]
@handle_request(r'^listplaylistinfo (?P<name>\w+)$')
@handle_request(r'^listplaylistinfo "(?P<name>[^"]+)"$')
@handle_request(r'listplaylistinfo\ ("?)(?P<name>[^"]+)\1$')
def listplaylistinfo(context, name):
"""
*musicpd.org, stored playlists section:*
@ -50,7 +48,7 @@ def listplaylistinfo(context, name):
return playlist_to_mpd_format(playlist)
@handle_request(r'^listplaylists$')
@handle_request(r'listplaylists$')
def listplaylists(context):
"""
*musicpd.org, stored playlists section:*
@ -92,7 +90,8 @@ def listplaylists(context):
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):
"""
*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])
@handle_request(r'^playlistadd "(?P<name>[^"]+)" "(?P<uri>[^"]+)"$')
@handle_request(r'playlistadd\ "(?P<name>[^"]+)"\ "(?P<uri>[^"]+)"$')
def playlistadd(context, name, uri):
"""
*musicpd.org, stored playlists section:*
@ -138,7 +137,7 @@ def playlistadd(context, name, uri):
raise MpdNotImplemented # TODO
@handle_request(r'^playlistclear "(?P<name>[^"]+)"$')
@handle_request(r'playlistclear\ "(?P<name>[^"]+)"$')
def playlistclear(context, name):
"""
*musicpd.org, stored playlists section:*
@ -150,7 +149,7 @@ def playlistclear(context, name):
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):
"""
*musicpd.org, stored playlists section:*
@ -163,8 +162,8 @@ def playlistdelete(context, name, songpos):
@handle_request(
r'^playlistmove "(?P<name>[^"]+)" '
r'"(?P<from_pos>\d+)" "(?P<to_pos>\d+)"$')
r'playlistmove\ "(?P<name>[^"]+)"\ '
r'"(?P<from_pos>\d+)"\ "(?P<to_pos>\d+)"$')
def playlistmove(context, name, from_pos, to_pos):
"""
*musicpd.org, stored playlists section:*
@ -183,7 +182,7 @@ def playlistmove(context, name, from_pos, to_pos):
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):
"""
*musicpd.org, stored playlists section:*
@ -195,7 +194,7 @@ def rename(context, old_name, new_name):
raise MpdNotImplemented # TODO
@handle_request(r'^rm "(?P<name>[^"]+)"$')
@handle_request(r'rm\ "(?P<name>[^"]+)"$')
def rm(context, name):
"""
*musicpd.org, stored playlists section:*
@ -207,7 +206,7 @@ def rm(context, name):
raise MpdNotImplemented # TODO
@handle_request(r'^save "(?P<name>[^"]+)"$')
@handle_request(r'save\ "(?P<name>[^"]+)"$')
def save(context, name):
"""
*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)),
('Title', track.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:
result.append(('Track', '%d/%d' % (
track.track_no, track.album.num_tracks)))
else:
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:
result.append(('Pos', position))
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 duplicate following code?
if track.album is not None and track.album.artists:
artists = artists_to_mpd_format(track.album.artists)
result.append(('AlbumArtist', artists))
artists = filter(
lambda a: a.musicbrainz_id is not None, track.album.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)
if artists:
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:
result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id))
return result
MPD_KEY_ORDER = '''
key file Time Artist AlbumArtist Title Album Track Date MUSICBRAINZ_ALBUMID
MUSICBRAINZ_ALBUMARTISTID MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime
key file Time Artist Album AlbumArtist Title Track Genre Date Composer
Performer Comment Disc MUSICBRAINZ_ALBUMID MUSICBRAINZ_ALBUMARTISTID
MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime
'''.split()
@ -166,7 +185,8 @@ def query_from_mpd_list_format(field, mpd_query):
key = tokens[0].lower()
value = tokens[1]
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')
if not value:
raise ValueError
@ -179,77 +199,6 @@ def query_from_mpd_list_format(field, mpd_query):
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.
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:
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'])
try:
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_uri = urllib.quote(relative_path)
# TODO: use track.last_modified
track_result['file'] = relative_uri
track_result['mtime'] = get_mtime(path)
track_result['key'] = os.path.basename(text_path)

View File

@ -219,6 +219,12 @@ class Track(ImmutableObject):
:type artists: list of :class:`Artist`
:param album: track 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
:type track_no: integer
:param disc_no: disc number in album
@ -229,6 +235,8 @@ class Track(ImmutableObject):
:type length: integer
:param bitrate: bitrate in kbit/s
:type bitrate: integer
:param comment: track comment
:type comment: string
:param musicbrainz_id: MusicBrainz ID
:type musicbrainz_id: string
:param last_modified: Represents last modification time
@ -247,6 +255,15 @@ class Track(ImmutableObject):
#: The track :class:`Album`. Read-only.
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.
track_no = 0
@ -262,6 +279,9 @@ class Track(ImmutableObject):
#: The track's bitrate in kbit/s. Read-only.
bitrate = None
#: The track comment. Read-only.
comment = None
#: The MusicBrainz ID of the track. Read-only.
musicbrainz_id = None
@ -272,6 +292,8 @@ class Track(ImmutableObject):
def __init__(self, *args, **kwargs):
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)

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.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):
setup_root_logger()
setup_console_logging(config, verbosity_level)
setup_log_levels(config)
if save_debug_log:
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']:
logging.config.fileConfig(config['logging']['config_file'])
logger = logging.getLogger('mopidy.utils.log')
logger.info('Starting Mopidy %s', versioning.get_version())
_delayed_handler.release()
def setup_log_levels(config):
@ -28,13 +51,8 @@ def setup_log_levels(config):
logging.getLogger(name).setLevel(level)
def setup_root_logger():
root = logging.getLogger('')
root.setLevel(logging.DEBUG)
def setup_console_logging(config, verbosity_level):
if verbosity_level == -1:
if verbosity_level < 0:
log_level = logging.WARNING
log_format = config['logging']['console_format']
elif verbosity_level >= 1:

View File

@ -37,14 +37,17 @@ def get_or_create_dir(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):
raise ValueError('Path is not a bytestring.')
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):
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

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={
'console_scripts': [
'mopidy = mopidy.__main__:main',
'mopidy-scan = mopidy.scanner:main',
'mopidy-convert-config = mopidy.config.convert:main',
],
'mopidy.ext': [

View File

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

View File

@ -20,12 +20,15 @@ class LocalLibraryProviderTest(unittest.TestCase):
Artist(name='artist2'),
Artist(name='artist3'),
Artist(name='artist4'),
Artist(name='artist5'),
Artist(name='artist6'),
]
albums = [
Album(name='album1', artists=[artists[0]]),
Album(name='album2', artists=[artists[1]]),
Album(name='album3', artists=[artists[2]]),
Album(name='album4'),
]
tracks = [
@ -41,6 +44,17 @@ class LocalLibraryProviderTest(unittest.TestCase):
uri='local:track:path3', name='track3',
artists=[artists[3]], album=albums[2],
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 = {
@ -102,7 +116,7 @@ class LocalLibraryProviderTest(unittest.TestCase):
self.assertEqual(tracks, [])
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), [])
result = self.library.find_exact(artist=['unknown artist'])
@ -111,18 +125,30 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(albumartist=['unknown albumartist'])
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), [])
result = self.library.find_exact(date=['1990'])
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'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(track_no=['no_match'])
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'])
self.assertEqual(list(result[0].tracks), [])
@ -138,11 +164,11 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(uri=track_2_uri)
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_find_exact_track(self):
result = self.library.find_exact(track=['track1'])
def test_find_exact_track_name(self):
result = self.library.find_exact(track_name=['track1'])
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])
def test_find_exact_artist(self):
@ -152,6 +178,23 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(artist=['artist2'])
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):
result = self.library.find_exact(album=['album1'])
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'])
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):
result = self.library.find_exact(date=['2001'])
self.assertEqual(list(result[0].tracks), [])
@ -189,6 +239,15 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(date=['2002'])
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):
# Matches on track artist
result = self.library.find_exact(any=['artist1'])
@ -197,7 +256,7 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.find_exact(any=['artist2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
# Matches on track
# Matches on track name
result = self.library.find_exact(any=['track1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -210,12 +269,33 @@ class LocalLibraryProviderTest(unittest.TestCase):
# Matches on track album artists
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'])
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
result = self.library.find_exact(any=['local:track:path1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -231,7 +311,13 @@ class LocalLibraryProviderTest(unittest.TestCase):
test = lambda: self.library.find_exact(albumartist=[''])
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)
test = lambda: self.library.find_exact(album=[''])
@ -240,14 +326,20 @@ class LocalLibraryProviderTest(unittest.TestCase):
test = lambda: self.library.find_exact(track_no=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(genre=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(date=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(comment=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(any=[''])
self.assertRaises(LookupError, test)
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), [])
result = self.library.search(artist=['unknown artist'])
@ -256,7 +348,13 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(albumartist=['unknown albumartist'])
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), [])
result = self.library.search(track_no=['9'])
@ -265,9 +363,15 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(track_no=['no_match'])
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'])
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'])
self.assertEqual(list(result[0].tracks), [])
@ -281,11 +385,11 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(uri=['TH2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_search_track(self):
result = self.library.search(track=['Rack1'])
def test_search_track_name(self):
result = self.library.search(track_name=['Rack1'])
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])
def test_search_artist(self):
@ -308,6 +412,14 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(albumartist=['Tist3'])
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):
result = self.library.search(album=['Bum1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -315,6 +427,13 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(album=['Bum2'])
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):
result = self.library.search(date=['2001'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -335,11 +454,26 @@ class LocalLibraryProviderTest(unittest.TestCase):
result = self.library.search(track_no=['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):
# Matches on track artist
result = self.library.search(any=['Tist1'])
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
result = self.library.search(any=['Rack1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -353,7 +487,22 @@ class LocalLibraryProviderTest(unittest.TestCase):
# Matches on track album artists
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
result = self.library.search(any=['TH1'])
@ -370,15 +519,27 @@ class LocalLibraryProviderTest(unittest.TestCase):
test = lambda: self.library.search(albumartist=[''])
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)
test = lambda: self.library.search(album=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(genre=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(date=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(comment=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(uri=[''])
self.assertRaises(LookupError, test)

View File

@ -71,34 +71,34 @@ class LocalTracklistProviderTest(unittest.TestCase):
def test_filter_by_tlid(self):
tl_track = self.controller.tl_tracks[1]
self.assertEqual(
[tl_track], self.controller.filter(tlid=tl_track.tlid))
[tl_track], self.controller.filter(tlid=[tl_track.tlid]))
@populate_tracklist
def test_filter_by_uri(self):
tl_track = self.controller.tl_tracks[1]
self.assertEqual(
[tl_track], self.controller.filter(uri=tl_track.track.uri))
[tl_track], self.controller.filter(uri=[tl_track.track.uri]))
@populate_tracklist
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):
track = Track(uri='a')
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):
track = Track(uri='a')
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[1].track)
def test_filter_by_uri_returns_nothing_if_no_match(self):
self.controller.playlist = Playlist(
tracks=[Track(uri='z'), Track(uri='y')])
self.assertEqual([], self.controller.filter(uri='a'))
tracks=[Track(uri=['z']), Track(uri=['y'])])
self.assertEqual([], self.controller.filter(uri=['a']))
def test_filter_by_multiple_criteria_returns_elements_matching_all(self):
track1 = Track(uri='a', name='x')
@ -106,18 +106,18 @@ class LocalTracklistProviderTest(unittest.TestCase):
track3 = Track(uri='b', name='y')
self.controller.add([track1, track2, track3])
self.assertEqual(
track1, self.controller.filter(uri='a', name='x')[0].track)
track1, self.controller.filter(uri=['a'], name=['x'])[0].track)
self.assertEqual(
track2, self.controller.filter(uri='b', name='x')[0].track)
track2, self.controller.filter(uri=['b'], name=['x'])[0].track)
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):
track1 = Track()
track2 = Track(uri='b')
track3 = Track()
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
def test_clear(self):
@ -227,17 +227,29 @@ class LocalTracklistProviderTest(unittest.TestCase):
track1 = self.controller.tracks[1]
track2 = self.controller.tracks[2]
version = self.controller.version
self.controller.remove(uri=track1.uri)
self.controller.remove(uri=[track1.uri])
self.assertLess(version, self.controller.version)
self.assertNotIn(track1, self.controller.tracks)
self.assertEqual(track2, self.controller.tracks[1])
@populate_tracklist
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):
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
def test_shuffle(self):

View File

@ -93,28 +93,30 @@ class URItoM3UTest(unittest.TestCase):
expected_artists = [Artist(name='name')]
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 = []
def generate_track(path, ident):
def generate_track(path, ident, album_id):
uri = 'local:track:%s' % path
track = Track(
uri=uri, name='trackname', artists=expected_artists,
album=expected_albums[0], track_no=1, date='2006', length=4000,
album=expected_albums[album_id], track_no=1, date='2006', length=4000,
last_modified=1272319626)
expected_tracks.append(track)
generate_track('song1.mp3', 6)
generate_track('song2.mp3', 7)
generate_track('song3.mp3', 8)
generate_track('subdir1/song4.mp3', 2)
generate_track('subdir1/song5.mp3', 3)
generate_track('subdir2/song6.mp3', 4)
generate_track('subdir2/song7.mp3', 5)
generate_track('subdir1/subsubdir/song8.mp3', 0)
generate_track('subdir1/subsubdir/song9.mp3', 1)
generate_track('song1.mp3', 6, 0)
generate_track('song2.mp3', 7, 0)
generate_track('song3.mp3', 8, 1)
generate_track('subdir1/song4.mp3', 2, 0)
generate_track('subdir1/song5.mp3', 3, 0)
generate_track('subdir2/song6.mp3', 4, 1)
generate_track('subdir2/song7.mp3', 5, 1)
generate_track('subdir1/subsubdir/song8.mp3', 0, 0)
generate_track('subdir1/subsubdir/song9.mp3', 1, 1)
class MPDTagCacheToTracksTest(unittest.TestCase):
@ -145,7 +147,9 @@ class MPDTagCacheToTracksTest(unittest.TestCase):
album = Album(name='æøå', artists=artists)
track = Track(
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])

View File

@ -1,6 +1,7 @@
from __future__ import unicode_literals
import argparse
import mock
import unittest
from mopidy import commands
@ -42,3 +43,450 @@ class ConfigOverrideTypeTest(unittest.TestCase):
self.assertRaises(
argparse.ArgumentTypeError,
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()
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')

View File

@ -37,7 +37,7 @@ class TracklistTest(unittest.TestCase):
self.assertEqual(tl_tracks, self.core.tracklist.tl_tracks[-1:])
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.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)
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.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)
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.assertListEqual(self.tl_tracks[:2], tl_tracks)
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.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

View File

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

View File

@ -6,6 +6,7 @@ songList begin
key: key1
file: /path1
Artist: artist1
AlbumArtist: artist1
Title: track1
Album: album1
Date: 2001-02-03
@ -14,6 +15,7 @@ Time: 4
key: key2
file: /path2
Artist: artist2
AlbumArtist: artist2
Title: track2
Album: album2
Date: 2002
@ -28,4 +30,27 @@ Album: album3
Date: 2003
Track: 3
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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,27 @@
from __future__ import unicode_literals
import unittest
from mopidy.frontends.mpd.protocol import music_db
from mopidy.models import Album, Artist, SearchResult, Track
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):
def test_count(self):
self.sendRequest('count "artist" "needle"')
@ -261,6 +278,22 @@ class MusicDatabaseFindTest(protocol.BaseTestCase):
self.sendRequest('find albumartist "what"')
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):
self.sendRequest('find "filename" "afilename"')
self.assertInResponse('OK')
@ -297,6 +330,14 @@ class MusicDatabaseFindTest(protocol.BaseTestCase):
self.sendRequest('find "track" ""')
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):
self.sendRequest('find "date" "2002-01-01"')
self.assertInResponse('OK')
@ -456,6 +497,135 @@ class MusicDatabaseListTest(protocol.BaseTestCase):
self.sendRequest('list "albumartist"')
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')
### Album
@ -492,6 +662,14 @@ class MusicDatabaseListTest(protocol.BaseTestCase):
self.sendRequest('list "album" "albumartist" "anartist"')
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):
self.sendRequest('list "album" "date" "2001-01-01"')
self.assertInResponse('OK')
@ -679,6 +857,30 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase):
self.sendRequest('search "albumartist" ""')
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):
self.sendRequest('search "filename" "afilename"')
self.assertInResponse('OK')
@ -739,6 +941,18 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase):
self.sendRequest('search "track" ""')
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):
self.sendRequest('search "date" "2002-01-01"')
self.assertInResponse('OK')
@ -755,6 +969,18 @@ class MusicDatabaseSearchTest(protocol.BaseTestCase):
self.sendRequest('search "date" ""')
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):
self.sendRequest('search "sometype" "something"')
self.assertEqualResponse('ACK [2@0] {search} incorrect arguments')

View File

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

View File

@ -17,7 +17,12 @@ class TrackMpdFormatTest(unittest.TestCase):
album=Album(name='an album', num_tracks=13,
artists=[Artist(name='an other artist')]),
track_no=7,
composers=[Artist(name='a composer')],
performers=[Artist(name='a performer')],
genre='a genre',
date=datetime.date(1977, 1, 1),
disc_no='1',
comment='a comment',
length=137000,
)
@ -36,8 +41,8 @@ class TrackMpdFormatTest(unittest.TestCase):
self.assertIn(('Title', ''), result)
self.assertIn(('Album', ''), result)
self.assertIn(('Track', 0), result)
self.assertIn(('Date', ''), result)
self.assertEqual(len(result), 7)
self.assertNotIn(('Date', ''), result)
self.assertEqual(len(result), 6)
def test_track_to_mpd_format_with_position(self):
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(('Album', 'an album'), 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(('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(('Id', 122), result)
self.assertEqual(len(result), 10)
self.assertEqual(len(result), 15)
def test_track_to_mpd_format_musicbrainz_trackid(self):
track = self.track.copy(musicbrainz_id='foo')
@ -118,20 +128,6 @@ class PlaylistMpdFormatTest(unittest.TestCase):
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):
def setUp(self):
self.media_dir = '/dir/subdir'

View File

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

View File

@ -450,12 +450,14 @@ class TrackTest(unittest.TestCase):
def test_repr_without_artists(self):
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')))
def test_repr_with_artists(self):
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')])))
def test_serialize_without_artists(self):
@ -670,7 +672,8 @@ class TlTrackTest(unittest.TestCase):
def test_repr(self):
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'))))
def test_serialize(self):
@ -804,8 +807,8 @@ class PlaylistTest(unittest.TestCase):
def test_repr_with_tracks(self):
self.assertEquals(
"Playlist(name=u'name', tracks=[Track(artists=[], name=u'foo')], "
"uri=u'uri')",
"Playlist(name=u'name', tracks=[Track(artists=[], composers=[], "
"name=u'foo', performers=[])], uri=u'uri')",
repr(Playlist(uri='uri', name='name', tracks=[Track(name='foo')])))
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):
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):
with self.assertRaises(ValueError):
file_path = unicode(os.path.join(self.parent, b'test'))
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):
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):
def test_simple_path(self):