Merge branch 'develop' into feature/filter_improvement

This commit is contained in:
Stein Magnus Jodal 2013-11-11 23:18:23 +01:00
commit 4e7c2bab4d
44 changed files with 1122 additions and 562 deletions

View File

@ -8,5 +8,5 @@ John Bäckstrand <sopues@gmail.com> <sandos@XBMCLive.(none)>
Alli Witheford <alzeih@gmail.com>
Alexandre Petitjean <alpetitjean@gmail.com>
Alexandre Petitjean <alpetitjean@gmail.com> <alexandre.petitjean@lne.fr>
Javier Domingo Cansino <javier.domingo@fon.com> <javierdo1@gmail.com>
Javier Domingo Cansino <javierdo1@gmail.com> <javier.domingo@fon.com>
Lasse Bigum <lasse@bigum.org> <l.bigum@samsung.com>

View File

@ -4,7 +4,7 @@ install:
- "wget -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -"
- "sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list"
- "sudo apt-get update || true"
- "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')"
- "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy|python:any/ {print $2}')"
- "pip install coveralls flake8"
before_script:

View File

@ -24,6 +24,8 @@
- Alli Witheford <alzeih@gmail.com>
- Alexandre Petitjean <alpetitjean@gmail.com>
- Terje Larsen <terlar@gmail.com>
- Javier Domingo Cansino <javier.domingo@fon.com>
- Javier Domingo Cansino <javierdo1@gmail.com>
- Pavol Babincak <scroolik@gmail.com>
- Javier Domingo <javierdo1@gmail.com>
- Lasse Bigum <lasse@bigum.org>
- David Eisner <david.eisner@oriel.oxon.org>

View File

@ -4,7 +4,13 @@
Authors
*******
Contributors to Mopidy in the order of appearance:
Mopidy is copyright 2009-2013 Stein Magnus Jodal and contributors. Mopidy is
licensed under the `Apache License, Version 2.0
<http://www.apache.org/licenses/LICENSE-2.0>`_.
The following persons have contributed to Mopidy. The list is in the order of
first contribution. For details on who have contributed what, please refer to
our Git repository.
.. include:: ../AUTHORS

View File

@ -4,6 +4,71 @@ Changelog
This changelog is used to track all major changes to Mopidy.
v0.17.0 (UNRELEASED)
====================
**Core**
- The search field ``track`` has been renamed to ``track_name`` to avoid
confusion with ``track_no``. (Fixes: :issue:`535`)
**Local backend**
- Library scanning has been switched back to custom code due to various issues
with GStreamer's built in scanner in 0.10. 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.
- Fix scanner so that time of last modification is respected when deciding
which files can be skipped.
**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`)
**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`)
v0.16.1 (2013-11-02)
====================
This is very small release to get Mopidy's Debian package ready for inclusion
in Debian.
**Commands**
- Fix removal of last dir level in paths to dependencies in
``mopidy --show-deps`` output.
- Add manpages for all commands.
**Local backend**
- Fix search filtering by track number that was added in 0.16.0.
**MPD frontend**
- Add support for ``list "albumartist" ...`` which was missed when ``find`` and
``search`` learned to handle ``albumartist`` in 0.16.0. (Fixes: :issue:`553`)
v0.16.0 (2013-10-27)
====================

13
docs/commands/index.rst Normal file
View File

@ -0,0 +1,13 @@
.. _commands:
********
Commands
********
Mopidy comes with the following commands:
.. toctree::
:maxdepth: 1
:glob:
**

View File

@ -0,0 +1,98 @@
.. _mopidy-convert-config:
*****************************
mopidy-convert-config command
*****************************
Synopsis
========
mopidy-convert-config
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-convert-config`` command is used to convert ``settings.py``
configuration files used by ``mopidy`` < 0.14 to the ``mopidy.conf`` config
file used by ``mopidy`` >= 0.14.
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.
Example
=======
Given the following contents in ``~/.config/mopidy/settings.py``:
::
LOCAL_MUSIC_PATH = u'~/music'
MPD_SERVER_HOSTNAME = u'::'
SPOTIFY_PASSWORD = u'secret'
SPOTIFY_USERNAME = u'alice'
Running ``mopidy-convert-config`` will convert the config and create a new
``mopidy.conf`` config file:
.. code-block:: none
$ mopidy-convert-config
Checking /home/alice/.config/mopidy/settings.py
Converted config:
[spotify]
username = alice
password = ********
[mpd]
hostname = ::
[local]
media_dir = ~/music
Write new config to /home/alice/.config/mopidy/mopidy.conf? [yN] y
Done.
Contents of ``~/.config/mopidy/mopidy.conf`` after the conversion:
.. code-block:: ini
[spotify]
username = alice
password = secret
[mpd]
hostname = ::
[local]
media_dir = ~/music
See also
========
:ref:`mopidy(1) <mopidy-cmd>`
Reporting bugs
==============
Report bugs to Mopidy's issue tracker at
<https://github.com/mopidy/mopidy/issues>

View File

@ -0,0 +1,59 @@
.. _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>

124
docs/commands/mopidy.rst Normal file
View File

@ -0,0 +1,124 @@
.. _mopidy-cmd:
**************
mopidy command
**************
Synopsis
========
mopidy
[-h] [--version] [-q] [-v] [--save-debug-log] [--show-config]
[--show-deps] [--config CONFIG_FILES] [-o CONFIG_OVERRIDES]
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`` command is used to start the server.
Options
=======
.. program:: mopidy
.. cmdoption:: -h, --help
Show help message and exit.
.. cmdoption:: --version
Show Mopidy's version number and exit.
.. cmdoption:: -q, --quiet
Show less output: warning level and higher.
.. cmdoption:: -v, --verbose
Show more output: debug level and higher.
.. cmdoption:: --save-debug-log
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
conflict.
.. cmdoption:: -o <option>, --option <option>
Specify additional config values in the ``section/key=value`` format. Can
be provided multiple times.
Files
=====
/etc/mopidy/mopidy.conf
System wide Mopidy configuration file.
~/.config/mopidy/mopidy.conf
Your personal Mopidy configuration file. Overrides any configs from the
system wide configuration file.
Examples
========
To start the music server, run::
mopidy
To start the server with an additional config file than can override configs
set in the default config files, run::
mopidy --config ./my-config.conf
To start the server and change a config value directly on the command line,
run::
mopidy --option mpd/enabled=false
The :option:`--option` flag may be repeated multiple times to change multiple
configs::
mopidy -o mpd/enabled=false -o spotify/bitrate=320
The :option:`--show-config` output shows the effect of the :option:`--option`
flags::
mopidy -o mpd/enabled=false -o spotify/bitrate=320 --show-config
See also
========
:ref:`mopidy-scan(1) <mopidy-scan-cmd>`, :ref:`mopidy-convert-config(1)
<mopidy-convert-config>`
Reporting bugs
==============
Report bugs to Mopidy's issue tracker at
<https://github.com/mopidy/mopidy/issues>

View File

@ -1,16 +1,6 @@
# -*- coding: utf-8 -*-
#
# Mopidy documentation build configuration file, created by
# sphinx-quickstart on Fri Feb 5 22:19:08 2010.
#
# This file is execfile()d with the current directory set to its containing
# dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# encoding: utf-8
"""Mopidy documentation build configuration file"""
from __future__ import unicode_literals
@ -18,6 +8,12 @@ import os
import sys
# -- Workarounds to have autodoc generate API docs ----------------------------
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../'))
class Mock(object):
def __init__(self, *args, **kwargs):
pass
@ -43,7 +39,6 @@ class Mock(object):
else:
return Mock()
MOCK_MODULES = [
'cherrypy',
'dbus',
@ -68,213 +63,8 @@ MOCK_MODULES = [
for mod_name in MOCK_MODULES:
sys.modules[mod_name] = Mock()
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../'))
# When RTD builds the project, it sets the READTHEDOCS environment variable to
# the string True.
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
# Enable Read the Docs' new theme
RTD_NEW_THEME = True
# -- General configuration ----------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.extlinks',
'sphinx.ext.graphviz',
'sphinx.ext.viewcode',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = 'Mopidy'
copyright = '2009-2013, Stein Magnus Jodal and contributors'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The full version, including alpha/beta/rc tags.
from mopidy.utils.versioning import get_version
release = get_version()
# The short X.Y version.
version = '.'.join(release.split('.')[:2])
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of documents that shouldn't be included in the build.
#unused_docs = []
# List of directories, relative to source directory, that shouldn't be searched
# for source files.
exclude_trees = ['_build']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
modindex_common_prefix = ['mopidy.']
# -- Options for HTML output --------------------------------------------------
# The theme to use for HTML and HTML Help pages. Major themes that come with
# Sphinx are currently 'default' and 'sphinxdoc'.
html_theme = 'default'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
html_theme_path = ['_themes']
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
if on_rtd:
html_logo = '_static/mopidy.png'
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_use_modindex = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = ''
# Output file base name for HTML help builder.
htmlhelp_basename = 'Mopidydoc'
# -- Options for LaTeX output -------------------------------------------------
# The paper size ('letter' or 'a4').
#latex_paper_size = 'letter'
# The font size ('10pt', '11pt' or '12pt').
#latex_font_size = '10pt'
# Grouping the document tree into LaTeX files. List of tuples (source start
# file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
(
'index',
'Mopidy.tex',
'Mopidy Documentation',
'Stein Magnus Jodal',
'manual'
),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# Additional stuff for the LaTeX preamble.
#latex_preamble = ''
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_use_modindex = True
needs_sphinx = '1.0'
extlinks = {'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#')}
# -- Custom Sphinx object types -----------------------------------------------
def setup(app):
from sphinx.ext.autodoc import cut_lines
@ -283,3 +73,91 @@ def setup(app):
b'confval', 'confval',
objname='configuration value',
indextemplate='pair: %s; configuration value')
# -- General configuration ----------------------------------------------------
needs_sphinx = '1.0'
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.extlinks',
'sphinx.ext.graphviz',
'sphinx.ext.viewcode',
]
templates_path = ['_templates']
source_suffix = '.rst'
master_doc = 'index'
project = 'Mopidy'
copyright = '2009-2013, Stein Magnus Jodal and contributors'
from mopidy.utils.versioning import get_version
release = get_version()
version = '.'.join(release.split('.')[:2])
exclude_trees = ['_build']
pygments_style = 'sphinx'
modindex_common_prefix = ['mopidy.']
# -- Options for HTML output --------------------------------------------------
html_theme = 'default'
html_theme_path = ['_themes']
html_static_path = ['_static']
html_use_modindex = True
html_use_index = True
html_split_index = False
html_show_sourcelink = True
htmlhelp_basename = 'Mopidy'
# -- Options for LaTeX output -------------------------------------------------
latex_documents = [
(
'index',
'Mopidy.tex',
'Mopidy Documentation',
'Stein Magnus Jodal and contributors',
'manual'
),
]
# -- Options for manpages output ----------------------------------------------
man_pages = [
(
'commands/mopidy',
'mopidy',
'music server',
'',
'1'
),
(
'commands/mopidy-scan',
'mopidy-scan',
'index music for playback with mopidy',
'',
'1'
),
(
'commands/mopidy-convert-config',
'mopidy-convert-config',
'migrate config files from mopidy pre-0.14',
'',
'1'
),
]
# -- Options for extlink extension --------------------------------------------
extlinks = {'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#')}

View File

@ -143,3 +143,73 @@ Creating releases
on the mailing list.
#. Update the Debian package.
Updating Debian packages
========================
This howto is not intended to learn you all the details, just to give someone
already familiar with Debian packaging an overview of how Mopidy's Debian
packages is maintained.
#. Install the basic packaging tools::
sudo apt-get install build-essential git-buildpackage
#. Check out the ``debian`` branch of the repo::
git checkout -t origin/debian
git pull
#. Merge the latest release tag into the ``debian`` branch::
git merge v0.16.0
#. Update the ``debian/changelog`` with a "New upstream release" entry::
dch -v 0.16.0-0mopidy1
git add debian/changelog
git commit -m "debian: New upstream release"
#. Check if any dependencies in ``debian/control`` or similar needs updating.
#. Install any Build-Deps listed in ``debian/control``.
#. Build the package and fix any issues repeatedly until the build succeeds and
the Lintian check at the end of the build is satisfactory::
git buildpackage -uc -us
#. Install and test newly built package::
sudo debi
#. If everything is OK, build the package a final time to tag the package
version::
git buildpackage -uc -us --git-tag
#. Push the changes you've done to the ``debian`` branch and the new tag::
git push
git push --tags
#. If you're building for multiple architectures, checkout the ``debian``
branch on the other builders and run::
git buildpackage -uc -us
#. Copy files to the APT server. Make sure to select the correct part of the
repo, e.g. main, contrib, or non-free::
scp ../mopidy*_0.16* bonobo.mopidy.com:/srv/apt.mopidy.com/app/incoming/stable/main
#. Update the APT repo::
ssh bonobo.mopidy.com
/srv/apt.mopidy.com/app/update.sh
#. Test installation from apt.mopidy.com::
sudo apt-get update
sudo apt-get dist-upgrade

View File

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

View File

@ -33,7 +33,6 @@ 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
@ -98,6 +97,13 @@ Configuration values
Number of seconds an MPD client can stay inactive before the connection is
closed by the server.
.. confval:: mpd/zeroconf
Name of the MPD service when published through Zeroconf. The variables
``$hostname`` and ``$port`` can be used in the name.
Set to an empty string to disable Zeroconf for MPD.
Usage
=====

View File

@ -48,7 +48,6 @@ About
:maxdepth: 1
authors
license
changelog
versioning
@ -72,6 +71,7 @@ Reference
:maxdepth: 2
glossary
commands/index
api/index
modules/index

View File

@ -71,25 +71,22 @@ it out.
Arch Linux: Install from AUR
============================
If you are running Arch Linux, you can install the latest release of Mopidy
using the `mopidy-git <https://aur.archlinux.org/packages/mopidy-git/>`_
package found in AUR. The package installs from the ``master`` branch of the
Mopidy Git repo, which always corresponds to the latest release.
If you are running Arch Linux, you can install Mopidy
using the `mopidy <https://aur.archlinux.org/packages/mopidy/>`_
package found in AUR.
#. To install Mopidy with GStreamer, libspotify and pyspotify, you can use
``packer``, ``yaourt``, or do it by hand like this::
#. To install Mopidy with all dependencies, you can use
for example `yaourt <https://wiki.archlinux.org/index.php/yaourt>`_::
wget http://aur.archlinux.org/packages/mopidy-git/mopidy-git.tar.gz
tar xf mopidy-git.tar.gz
cd mopidy-git/
makepkg -si
yaourt -S mopidy
To upgrade Mopidy to future releases, just rerun ``makepkg``.
To upgrade Mopidy to future releases, just upgrade your system using::
#. Optional: If you want to scrobble your played tracks to Last.fm, you need to
install `python2-pylast`::
yaourt -Syu
sudo pacman -S python2-pylast
#. Optional: If you want to use any Mopidy extensions, like Spotify support or
Last.fm scrobbling, AUR also got `packages for several Mopidy extensions
<https://aur.archlinux.org/packages/?K=mopidy>`_.
#. Finally, you need to set a couple of :doc:`config values </config>`, and
then you're ready to :doc:`run Mopidy </running>`.

View File

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

View File

@ -6,10 +6,17 @@ 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`.
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
:doc:`/clients/mpd` list to find recommended clients.
Stopping Mopidy
===============
To stop Mopidy, press ``CTRL+C`` in the terminal where you started Mopidy.
Mopidy will also shut down properly if you send it the TERM signal, e.g. by
@ -18,124 +25,3 @@ using ``kill``::
kill `ps ax | grep mopidy | grep -v grep | cut -d' ' -f1`
This can be useful e.g. if you create init script for managing Mopidy.
mopidy command
==============
.. program:: mopidy
.. 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.
.. cmdoption:: --save-debug-log
Save debug log to the file specified in the :confval:`logging/debug_file`
config value, typically ``./mopidy.conf``.
.. 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
conflict.
.. cmdoption:: -o <option>, --option <option>
Specify additional config values in the ``section/key=value`` format. Can
be provided multiple times.
mopidy-scan command
===================
.. 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.
.. _mopidy-convert-config:
mopidy-convert-config command
=============================
.. 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.
Example usage::
$ cat ~/.config/mopidy/settings.py
LOCAL_MUSIC_PATH = u'~/music'
MPD_SERVER_HOSTNAME = u'::'
SPOTIFY_PASSWORD = u'secret'
SPOTIFY_USERNAME = u'alice'
$ mopidy-convert-config
Checking /home/alice/.config/mopidy/settings.py
Converted config:
[spotify]
username = alice
password = ********
[mpd]
hostname = ::
[local]
media_dir = ~/music
Write new config to /home/alice/.config/mopidy/mopidy.conf? [yN] y
Done.
$ cat ~/.config/mopidy/mopidy.conf
[spotify]
username = alice
password = secret
[mpd]
hostname = ::
[local]
media_dir = ~/music

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

@ -0,0 +1,155 @@
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):
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):
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 = {}
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] // 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)]
return Track(**track_kwargs)

View File

@ -8,7 +8,7 @@ from mopidy.backends import base
from mopidy.frontends.mpd import translator as mpd_translator
from mopidy.models import Album, SearchResult
from .translator import parse_mpd_tag_cache
from .translator import local_to_file_uri, parse_mpd_tag_cache
logger = logging.getLogger('mopidy.backends.local')
@ -21,6 +21,12 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
self._tag_cache_file = self.backend.config['local']['tag_cache_file']
self.refresh()
def _convert_to_int(self, string):
try:
return int(string)
except ValueError:
return object()
def refresh(self, uri=None):
logger.debug(
'Loading local tracks from %s using %s',
@ -61,12 +67,12 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
# FIXME this is bound to be slow for large libraries
for value in values:
if field == 'track_no':
q = value
q = self._convert_to_int(value)
else:
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)
@ -77,17 +83,16 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
date_filter = lambda t: q == t.date
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
track_no_filter(t) or
date_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':
@ -119,12 +124,12 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
# FIXME this is bound to be slow for large libraries
for value in values:
if field == 'track_no':
q = value
q = self._convert_to_int(value)
else:
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(
@ -136,17 +141,16 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
date_filter = lambda t: t.date and t.date.startswith(q)
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
track_no_filter(t) or
date_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':
@ -185,7 +189,10 @@ class LocalLibraryUpdateProvider(base.BaseLibraryProvider):
def load(self):
tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir)
for track in tracks:
self._tracks[track.uri] = track
# TODO: this should use uris as is, i.e. hack that should go away
# with tag caches.
uri = local_to_file_uri(track.uri, self._media_dir)
self._tracks[uri] = track.copy(uri=uri)
return tracks
def add(self, track):

View File

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

View File

@ -6,11 +6,18 @@ import urlparse
from mopidy.models import Track, Artist, Album
from mopidy.utils.encoding import locale_decode
from mopidy.utils.path import path_to_uri
from mopidy.utils.path import path_to_uri, uri_to_path
logger = logging.getLogger('mopidy.backends.local')
def local_to_file_uri(uri, media_dir):
# TODO: check that type is correct.
file_path = uri_to_path(uri).split(b':', 1)[1]
file_path = os.path.join(media_dir, file_path)
return path_to_uri(file_path)
def parse_m3u(file_path, media_dir):
r"""
Convert M3U file list of uris
@ -120,7 +127,6 @@ 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']

View File

@ -163,7 +163,7 @@ def validate_extension(extension):
def register_gstreamer_elements(enabled_extensions):
"""Registers custom GStreamer elements from extensions.
:params enabled_extensions: list of enabled extensions
:param enabled_extensions: list of enabled extensions
"""
for extension in enabled_extensions:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -127,8 +127,8 @@ def findadd(context, mpd_query):
@handle_request(
r'^list "?(?P<field>([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?'
r'( (?P<mpd_query>.*))?$')
r'^list "?(?P<field>([Aa]rtist|[Aa]lbumartist|[Aa]lbum|[Dd]ate|'
r'[Gg]enre))"?( (?P<mpd_query>.*))?$')
def list_(context, field, mpd_query=None):
"""
*musicpd.org, music database section:*
@ -136,7 +136,7 @@ def list_(context, field, mpd_query=None):
``list {TYPE} [ARTIST]``
Lists all tags of the specified type. ``TYPE`` should be ``album``,
``artist``, ``date``, or ``genre``.
``artist``, ``albumartist``, ``date``, or ``genre``.
``ARTIST`` is an optional parameter when type is ``album``,
``date``, or ``genre``. This filters the result list by an artist.
@ -218,6 +218,8 @@ def list_(context, field, mpd_query=None):
return
if field == 'artist':
return _list_artist(context, query)
if field == 'albumartist':
return _list_albumartist(context, query)
elif field == 'album':
return _list_album(context, query)
elif field == 'date':
@ -236,6 +238,17 @@ def _list_artist(context, query):
return artists
def _list_albumartist(context, query):
albumartists = set()
results = context.core.library.find_exact(**query).get()
for track in _get_tracks(results):
if track.album:
for artist in track.album.artists:
if artist.name:
albumartists.add(('AlbumArtist', artist.name))
return albumartists
def _list_album(context, query):
albums = set()
results = context.core.library.find_exact(**query).get()

View File

@ -44,9 +44,6 @@ def track_to_mpd_format(track, position=None):
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 +52,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:
@ -235,7 +234,7 @@ def query_from_mpd_search_format(mpd_query):
m = MPD_SEARCH_QUERY_PART_RE.match(query_part)
field = m.groupdict()['field'].lower()
if field == 'title':
field = 'track'
field = 'track_name'
elif field == 'track':
field = 'track_no'
elif field in ('file', 'filename'):
@ -302,6 +301,7 @@ def _add_to_tag_cache(result, dirs, files, media_dir):
relative_path = os.path.relpath(path, base_path)
relative_uri = urllib.quote(relative_path)
# TODO: use track.last_modified
track_result['file'] = relative_uri
track_result['mtime'] = get_mtime(path)
track_result['key'] = os.path.basename(text_path)

View File

@ -1,28 +1,22 @@
from __future__ import unicode_literals
import argparse
import datetime
import logging
import os
import sys
import time
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.audio import scan
from mopidy.backends.local import translator
from mopidy.utils import log, path, versioning
@ -73,6 +67,8 @@ def main():
media_dir = config['local']['media_dir']
excluded_extensions = config['local']['excluded_file_extensions']
# 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()
@ -80,18 +76,20 @@ def main():
logging.info('Checking tracks from library.')
for track in local_updater.load():
try:
stat = os.stat(path.uri_to_path(track.uri))
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(track.uri)
uris_library.add(track.uri)
uris_update.add(uri)
uris_library.add(uri)
except OSError:
logging.debug('Missing file %s', track.uri)
uris_remove.add(track.uri)
logging.info('Removing %d moved or deleted tracks.', len(uris_remove))
logging.info('Removing %d missing tracks.', len(uris_remove))
for uri in uris_remove:
local_updater.remove(uri)
logging.info('Checking %s for new or modified tracks.', media_dir)
logging.info('Checking %s for unknown 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)
@ -100,24 +98,42 @@ def main():
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.')
logging.info('Found %d unknown tracks.', len(uris_update))
logging.info('Scanning...')
scanner = Scanner(config['local']['scan_timeout'])
for uri in uris_update:
scanner = scan.Scanner(config['local']['scan_timeout'])
progress = Progress(len(uris_update))
for uri in sorted(uris_update):
try:
data = scanner.scan(uri)
data[b'mtime'] = os.path.getmtime(path.uri_to_path(uri))
track = translator(data)
track = scan.audio_data_to_track(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.')
progress.increment()
logging.info('Commiting changes.')
local_updater.commit()
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)
logging.info('Scanned %d of %d files in %ds, ~%ds left.',
self.count, self.total, duration, remainder)
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument(
@ -134,95 +150,5 @@ def parse_args():
return parser.parse_args(args=mopidy_args)
# TODO: move into scanner.
def translator(data):
albumartist_kwargs = {}
album_kwargs = {}
artist_kwargs = {}
track_kwargs = {}
def _retrieve(source_key, target_key, target):
if source_key in data:
target[target_key] = data[source_key]
_retrieve(gst.TAG_ALBUM, 'name', album_kwargs)
_retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs)
_retrieve(gst.TAG_ALBUM_VOLUME_COUNT, 'num_discs', album_kwargs)
_retrieve(gst.TAG_ARTIST, 'name', artist_kwargs)
if gst.TAG_DATE in data and data[gst.TAG_DATE]:
date = data[gst.TAG_DATE]
try:
date = datetime.date(date.year, date.month, date.day)
except ValueError:
pass # Ignore invalid dates
else:
track_kwargs['date'] = date.isoformat()
_retrieve(gst.TAG_TITLE, 'name', track_kwargs)
_retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs)
_retrieve(gst.TAG_ALBUM_VOLUME_NUMBER, 'disc_no', track_kwargs)
# Following keys don't seem to have TAG_* constant.
_retrieve('album-artist', 'name', albumartist_kwargs)
_retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs)
_retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs)
_retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs)
_retrieve(
'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs)
if albumartist_kwargs:
album_kwargs['artists'] = [Artist(**albumartist_kwargs)]
track_kwargs['uri'] = data['uri']
track_kwargs['last_modified'] = int(data['mtime'])
track_kwargs['length'] = data[gst.TAG_DURATION]
track_kwargs['album'] = Album(**album_kwargs)
track_kwargs['artists'] = [Artist(**artist_kwargs)]
return Track(**track_kwargs)
class Scanner(object):
def __init__(self, timeout=1000):
self.discoverer = gst.pbutils.Discoverer(timeout * 1000000)
def scan(self, uri):
try:
info = self.discoverer.discover_uri(uri)
except gobject.GError as e:
# Loosing traceback is non-issue since this is from C code.
raise exceptions.ScannerError(e)
data = {}
audio_streams = info.get_audio_streams()
if not audio_streams:
raise exceptions.ScannerError('Did not find any audio streams.')
for stream in audio_streams:
taglist = stream.get_tags()
if not taglist:
continue
for key in taglist.keys():
# XXX: For some crazy reason some wma files spit out lists
# here, not sure if this is due to better data in headers or
# wma being stupid. So ugly hack for now :/
if type(taglist[key]) is list:
data[key] = taglist[key][0]
else:
data[key] = taglist[key]
# Never trust metadata for these fields:
data[b'uri'] = uri
data[b'duration'] = info.get_duration() // gst.MSECOND
if data[b'duration'] < 100:
raise exceptions.ScannerError(
'Rejecting file with less than 100ms audio data.')
return data
if __name__ == '__main__':
main()

View File

@ -41,7 +41,7 @@ def _format_dependency(dep_info):
lines.append('%s: not found' % dep_info['name'])
else:
if 'path' in dep_info:
source = ' from %s' % os.path.dirname(dep_info['path'])
source = ' from %s' % dep_info['path']
else:
source = ''
lines.append('%s: %s%s' % (
@ -75,7 +75,7 @@ def python_info():
'name': 'Python',
'version': '%s %s' % (
platform.python_implementation(), platform.python_version()),
'path': platform.__file__,
'path': os.path.dirname(platform.__file__),
}
@ -127,7 +127,7 @@ def gstreamer_info():
return {
'name': 'GStreamer',
'version': '.'.join(map(str, gst.get_gst_version())),
'path': gst.__file__,
'path': os.path.dirname(gst.__file__),
'other': '\n'.join(other),
}

View File

@ -12,9 +12,8 @@ def setup_logging(config, verbosity_level, save_debug_log):
setup_console_logging(config, verbosity_level)
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'])

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

@ -0,0 +1,81 @@
from __future__ import unicode_literals
import logging
import re
import socket
import string
logger = logging.getLogger('mopidy.utils.zerconf')
try:
import dbus
except ImportError:
dbus = None
_AVAHI_IF_UNSPEC = -1
_AVAHI_PROTO_UNSPEC = -1
_AVAHI_PUBLISHFLAGS_NONE = 0
def _filter_loopback_and_meta_addresses(host):
# TODO: see if we can find a cleaner way of handling this.
if re.search(r'(?<![.\d])(127|0)[.]', host):
return ''
return host
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 []
self.host = _filter_loopback_and_meta_addresses(host or '')
template = string.Template(name)
self.name = template.safe_substitute(
hostname=self.host or socket.getfqdn(), port=self.port)
def publish(self):
if not dbus:
logger.debug('Zeroconf publish failed: dbus not installed.')
return False
try:
bus = dbus.SystemBus()
except dbus.exceptions.DBusException as e:
logger.debug('Zeroconf publish failed: %s', e)
return False
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
def unpublish(self):
if self.group:
self.group.Reset()
self.group = None

View File

@ -3,8 +3,8 @@ from __future__ import unicode_literals
import unittest
from mopidy import exceptions
from mopidy.audio import scan
from mopidy.models import Track, Artist, Album
from mopidy.scanner import Scanner, translator
from mopidy.utils import path as path_lib
from tests import path_to_data_dir
@ -31,7 +31,7 @@ class TranslatorTest(unittest.TestCase):
'album-disc-count': 3,
'date': FakeGstDate(2006, 1, 1,),
'container-format': 'ID3 tag',
'duration': 4531,
'duration': 4531000000,
'musicbrainz-trackid': 'mbtrackid',
'musicbrainz-albumid': 'mbalbumid',
'musicbrainz-artistid': 'mbartistid',
@ -46,11 +46,18 @@ 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.albumartist = {
'name': 'albumartistname',
'musicbrainz_id': 'mbalbumartistid',
@ -71,12 +78,19 @@ 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)]
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):
@ -122,6 +136,12 @@ class TranslatorTest(unittest.TestCase):
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']
@ -151,7 +171,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 +202,8 @@ class ScannerTest(unittest.TestCase):
def test_duration_is_set(self):
self.scan('scanner/simple')
self.check('scanner/simple/song1.mp3', 'duration', 4680)
self.check('scanner/simple/song1.ogg', 'duration', 4680)
self.check('scanner/simple/song1.mp3', 'duration', 4680000000)
self.check('scanner/simple/song1.ogg', 'duration', 4680000000)
def test_artist_is_set(self):
self.scan('scanner/simple')

View File

@ -26,6 +26,7 @@ class LocalLibraryProviderTest(unittest.TestCase):
Album(name='album1', artists=[artists[0]]),
Album(name='album2', artists=[artists[1]]),
Album(name='album3', artists=[artists[2]]),
Album(name='album4'),
]
tracks = [
@ -41,6 +42,10 @@ 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),
]
config = {
@ -102,19 +107,25 @@ 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'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(albumartist=['unknown albumartist'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(album=['unknown artist'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(date=['1990'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(track_no=[9])
result = self.library.find_exact(track_no=['9'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(track_no=['no_match'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.find_exact(uri=['fake uri'])
@ -133,10 +144,10 @@ class LocalLibraryProviderTest(unittest.TestCase):
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_find_exact_track(self):
result = self.library.find_exact(track=['track1'])
result = self.library.find_exact(track_name=['track1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
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):
@ -146,6 +157,9 @@ 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_album(self):
result = self.library.find_exact(album=['album1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
@ -167,10 +181,10 @@ class LocalLibraryProviderTest(unittest.TestCase):
self.assertEqual(list(result[0].tracks), [self.tracks[2]])
def test_find_exact_track_no(self):
result = self.library.find_exact(track_no=[1])
result = self.library.find_exact(track_no=['1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.find_exact(track_no=[2])
result = self.library.find_exact(track_no=['2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_find_exact_date(self):
@ -204,7 +218,8 @@ 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
result = self.library.find_exact(any=['2002'])
@ -222,13 +237,16 @@ class LocalLibraryProviderTest(unittest.TestCase):
test = lambda: self.library.find_exact(artist=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(track=[''])
test = lambda: self.library.find_exact(albumartist=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(track_name=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(album=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(track_no=[])
test = lambda: self.library.find_exact(track_no=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.find_exact(date=[''])
@ -238,16 +256,22 @@ class LocalLibraryProviderTest(unittest.TestCase):
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'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(albumartist=['unknown albumartist'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(album=['unknown artist'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(track_no=[9])
result = self.library.search(track_no=['9'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(track_no=['no_match'])
self.assertEqual(list(result[0].tracks), [])
result = self.library.search(date=['unknown date'])
@ -267,10 +291,10 @@ class LocalLibraryProviderTest(unittest.TestCase):
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_search_track(self):
result = self.library.search(track=['Rack1'])
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):
@ -314,10 +338,10 @@ class LocalLibraryProviderTest(unittest.TestCase):
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_search_track_no(self):
result = self.library.search(track_no=[1])
result = self.library.search(track_no=['1'])
self.assertEqual(list(result[0].tracks), self.tracks[:1])
result = self.library.search(track_no=[2])
result = self.library.search(track_no=['2'])
self.assertEqual(list(result[0].tracks), self.tracks[1:2])
def test_search_any(self):
@ -338,7 +362,8 @@ 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 URI
result = self.library.search(any=['TH1'])
@ -352,7 +377,10 @@ class LocalLibraryProviderTest(unittest.TestCase):
test = lambda: self.library.search(artist=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(track=[''])
test = lambda: self.library.search(albumartist=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(track_name=[''])
self.assertRaises(LookupError, test)
test = lambda: self.library.search(album=[''])

View File

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

View File

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

View File

@ -6,6 +6,7 @@ songList begin
key: key1
file: /path1
Artist: artist1
AlbumArtist: artist1
Title: track1
Album: album1
Date: 2001-02-03
@ -14,6 +15,7 @@ Time: 4
key: key2
file: /path2
Artist: artist2
AlbumArtist: artist2
Title: track2
Album: album2
Date: 2002
@ -28,4 +30,12 @@ Album: album3
Date: 2003
Track: 3
Time: 4
key: key4
file: /path4
Artist: artist3
Title: track4
Album: album4
Date: 2004
Track: 4
Time: 60
songList end

View File

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

View File

@ -7,6 +7,7 @@ key: song1.mp3
file: /song1.mp3
Time: 4
Artist: æøå
AlbumArtist: æøå
Title: æøå
Album: æøå
mtime: 1272319626

View File

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

View File

@ -398,6 +398,66 @@ class MusicDatabaseListTest(protocol.BaseTestCase):
self.assertNotInResponse('Artist: ')
self.assertInResponse('OK')
### Albumartist
def test_list_albumartist_with_quotes(self):
self.sendRequest('list "albumartist"')
self.assertInResponse('OK')
def test_list_albumartist_without_quotes(self):
self.sendRequest('list albumartist')
self.assertInResponse('OK')
def test_list_albumartist_without_quotes_and_capitalized(self):
self.sendRequest('list Albumartist')
self.assertInResponse('OK')
def test_list_albumartist_with_query_of_one_token(self):
self.sendRequest('list "albumartist" "anartist"')
self.assertEqualResponse(
'ACK [2@0] {list} should be "Album" for 3 arguments')
def test_list_albumartist_with_unknown_field_in_query_returns_ack(self):
self.sendRequest('list "albumartist" "foo" "bar"')
self.assertEqualResponse('ACK [2@0] {list} not able to parse args')
def test_list_albumartist_by_artist(self):
self.sendRequest('list "albumartist" "artist" "anartist"')
self.assertInResponse('OK')
def test_list_albumartist_by_album(self):
self.sendRequest('list "albumartist" "album" "analbum"')
self.assertInResponse('OK')
def test_list_albumartist_by_full_date(self):
self.sendRequest('list "albumartist" "date" "2001-01-01"')
self.assertInResponse('OK')
def test_list_albumartist_by_year(self):
self.sendRequest('list "albumartist" "date" "2001"')
self.assertInResponse('OK')
def test_list_albumartist_by_genre(self):
self.sendRequest('list "albumartist" "genre" "agenre"')
self.assertInResponse('OK')
def test_list_albumartist_by_artist_and_album(self):
self.sendRequest(
'list "albumartist" "artist" "anartist" "album" "analbum"')
self.assertInResponse('OK')
def test_list_albumartist_without_filter_value(self):
self.sendRequest('list "albumartist" "artist" ""')
self.assertInResponse('OK')
def test_list_albumartist_should_not_return_artists_without_names(self):
self.backend.library.dummy_find_exact_result = SearchResult(
tracks=[Track(album=Album(artists=[Artist(name='')]))])
self.sendRequest('list "albumartist"')
self.assertNotInResponse('Artist: ')
self.assertInResponse('OK')
### Album
def test_list_album_with_quotes(self):

View File

@ -20,7 +20,7 @@ class DepsTest(unittest.TestCase):
lambda: dict(name='Platform', version='Loonix 4.0.1'),
lambda: dict(
name='Pykka', version='1.1',
path='/foo/bar/baz.py', other='Quux'),
path='/foo/bar', other='Quux'),
lambda: dict(name='Foo'),
lambda: dict(name='Mopidy', version='0.13', dependencies=[
dict(name='pylast', version='0.5', dependencies=[
@ -58,6 +58,7 @@ class DepsTest(unittest.TestCase):
self.assertIn(platform.python_implementation(), result['version'])
self.assertIn(platform.python_version(), result['version'])
self.assertIn('python', result['path'])
self.assertNotIn('platform.py', result['path'])
def test_gstreamer_info(self):
result = deps.gstreamer_info()
@ -66,6 +67,7 @@ class DepsTest(unittest.TestCase):
self.assertEquals(
'.'.join(map(str, gst.get_gst_version())), result['version'])
self.assertIn('gst', result['path'])
self.assertNotIn('__init__.py', result['path'])
self.assertIn('Python wrapper: gst-python', result['other'])
self.assertIn(
'.'.join(map(str, gst.get_pygst_version())), result['other'])