Release v0.17.0
This commit is contained in:
commit
2b7bc870cf
1
AUTHORS
1
AUTHORS
@ -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>
|
||||
|
||||
@ -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
9
docs/api/commands.rst
Normal file
@ -0,0 +1,9 @@
|
||||
.. _commands-api:
|
||||
|
||||
************
|
||||
Commands API
|
||||
************
|
||||
|
||||
.. automodule:: mopidy.commands
|
||||
:synopsis: Commands API for Mopidy CLI.
|
||||
:members:
|
||||
@ -13,6 +13,7 @@ API reference
|
||||
core
|
||||
audio
|
||||
frontends
|
||||
commands
|
||||
ext
|
||||
config
|
||||
http
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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
|
||||
==============
|
||||
|
||||
18
docs/conf.py
18
docs/conf.py
@ -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#'),
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
=====
|
||||
|
||||
@ -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!
|
||||
|
||||
@ -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
|
||||
=====
|
||||
|
||||
@ -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
|
||||
=========================
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -21,4 +21,4 @@ if (isinstance(pykka.__version__, basestring)
|
||||
warnings.filterwarnings('ignore', 'could not open display')
|
||||
|
||||
|
||||
__version__ = '0.16.1'
|
||||
__version__ = '0.17.0'
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
193
mopidy/audio/scan.py
Normal 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)
|
||||
@ -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()
|
||||
|
||||
115
mopidy/backends/local/commands.py
Normal file
115
mopidy/backends/local/commands.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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']
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -8,7 +8,7 @@ config_file =
|
||||
pykka = info
|
||||
|
||||
[audio]
|
||||
mixer = autoaudiomixer
|
||||
mixer = software
|
||||
mixer_track =
|
||||
output = autoaudiosink
|
||||
visualizer =
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -3,6 +3,7 @@ enabled = true
|
||||
hostname = 127.0.0.1
|
||||
port = 6680
|
||||
static_dir =
|
||||
zeroconf = Mopidy HTTP server on $hostname
|
||||
|
||||
[loglevels]
|
||||
cherrypy = warning
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -5,3 +5,4 @@ port = 6600
|
||||
password =
|
||||
max_connections = 20
|
||||
connection_timeout = 60
|
||||
zeroconf = Mopidy MPD server on $hostname
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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:*
|
||||
|
||||
@ -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:*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:*
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:*
|
||||
|
||||
@ -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:*
|
||||
|
||||
@ -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:*
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:*
|
||||
|
||||
@ -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:*
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
@ -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:
|
||||
|
||||
@ -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
92
mopidy/utils/zeroconf.py
Normal 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
|
||||
1
setup.py
1
setup.py
@ -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': [
|
||||
|
||||
@ -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')
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -7,6 +7,7 @@ key: song1.mp3
|
||||
file: /song1.mp3
|
||||
Time: 4
|
||||
Artist: name
|
||||
AlbumArtist: name
|
||||
Title: trackname
|
||||
Album: albumname
|
||||
Track: 1/2
|
||||
|
||||
@ -7,7 +7,12 @@ key: song1.mp3
|
||||
file: /song1.mp3
|
||||
Time: 4
|
||||
Artist: æøå
|
||||
AlbumArtist: æøå
|
||||
Composer: æøå
|
||||
Performer: æøå
|
||||
Title: æøå
|
||||
Album: æøå
|
||||
Genre: æøå
|
||||
Comment: æøå&^`ൂ㔶
|
||||
mtime: 1272319626
|
||||
songList end
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user