Merge branch 'develop' into feature/filter_improvement
This commit is contained in:
commit
4e7c2bab4d
2
.mailmap
2
.mailmap
@ -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>
|
||||
|
||||
@ -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:
|
||||
|
||||
4
AUTHORS
4
AUTHORS
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
13
docs/commands/index.rst
Normal file
@ -0,0 +1,13 @@
|
||||
.. _commands:
|
||||
|
||||
********
|
||||
Commands
|
||||
********
|
||||
|
||||
Mopidy comes with the following commands:
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:glob:
|
||||
|
||||
**
|
||||
98
docs/commands/mopidy-convert-config.rst
Normal file
98
docs/commands/mopidy-convert-config.rst
Normal 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>
|
||||
59
docs/commands/mopidy-scan.rst
Normal file
59
docs/commands/mopidy-scan.rst
Normal 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
124
docs/commands/mopidy.rst
Normal 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>
|
||||
318
docs/conf.py
318
docs/conf.py
@ -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', '#')}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
=====
|
||||
|
||||
@ -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
|
||||
=====
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>`.
|
||||
|
||||
@ -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>`_.
|
||||
128
docs/running.rst
128
docs/running.rst
@ -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
155
mopidy/audio/scan.py
Normal 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)
|
||||
@ -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):
|
||||
|
||||
@ -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,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']
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
|
||||
@ -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
81
mopidy/utils/zeroconf.py
Normal 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
|
||||
@ -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')
|
||||
@ -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=[''])
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,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
|
||||
|
||||
@ -7,6 +7,7 @@ key: song1.mp3
|
||||
file: /song1.mp3
|
||||
Time: 4
|
||||
Artist: name
|
||||
AlbumArtist: name
|
||||
Title: trackname
|
||||
Album: albumname
|
||||
Track: 1/2
|
||||
|
||||
@ -7,6 +7,7 @@ key: song1.mp3
|
||||
file: /song1.mp3
|
||||
Time: 4
|
||||
Artist: æøå
|
||||
AlbumArtist: æøå
|
||||
Title: æøå
|
||||
Album: æøå
|
||||
mtime: 1272319626
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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'])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user