Release v1.1.2
This commit is contained in:
commit
2ba5bdc822
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,6 +3,7 @@
|
||||
*.pyc
|
||||
*.swp
|
||||
*~
|
||||
.cache/
|
||||
.coverage
|
||||
.idea
|
||||
.noseids
|
||||
@ -15,5 +16,5 @@ dist/
|
||||
docs/_build/
|
||||
mopidy.log*
|
||||
nosetests.xml
|
||||
xunit-*.xml
|
||||
tmp/
|
||||
xunit-*.xml
|
||||
|
||||
@ -55,20 +55,28 @@ Data model API
|
||||
:synopsis: Data model API
|
||||
|
||||
.. autoclass:: mopidy.models.Ref
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.Track
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.Album
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.Artist
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.Playlist
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.Image
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.TlTrack
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.SearchResult
|
||||
:members:
|
||||
|
||||
|
||||
Data model helpers
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
Authors
|
||||
*******
|
||||
|
||||
Mopidy is copyright 2009-2015 Stein Magnus Jodal and contributors. Mopidy is
|
||||
Mopidy is copyright 2009-2016 Stein Magnus Jodal and contributors. Mopidy is
|
||||
licensed under the `Apache License, Version 2.0
|
||||
<http://www.apache.org/licenses/LICENSE-2.0>`_.
|
||||
|
||||
|
||||
@ -4,6 +4,48 @@ Changelog
|
||||
|
||||
This changelog is used to track all major changes to Mopidy.
|
||||
|
||||
v1.1.2 (2016-01-18)
|
||||
===================
|
||||
|
||||
Bug fix release.
|
||||
|
||||
- Main: Catch errors when loading the :confval:`logging/config_file` file.
|
||||
(Fixes: :issue:`1320`)
|
||||
|
||||
- Core: If changing to another track while the player is paused, the new track
|
||||
would not be added to the history or marked as currently playing. (Fixes:
|
||||
:issue:`1352`, PR: :issue:`1356`)
|
||||
|
||||
- Core: Skips over unplayable tracks if the user attempts to change tracks
|
||||
while paused, like we already did if in playing state. (Fixes :issue:`1378`,
|
||||
PR: :issue:`1379`)
|
||||
|
||||
- Core: Make :meth:`~mopidy.core.LibraryController.lookup` ignore tracks with
|
||||
empty URIs. (Partly fixes: :issue:`1340`, PR: :issue:`1381`)
|
||||
|
||||
- Core: Fix crash if backends emits events with wrong names or arguments.
|
||||
(Fixes: :issue:`1383`)
|
||||
|
||||
- Stream: If an URI is considered playable, don't consider it as a candidate
|
||||
for playlist parsing. Just looking at MIME type prefixes isn't enough, as for
|
||||
example Ogg Vorbis has the MIME type ``application/ogg``. (Fixes:
|
||||
:issue:`1299`)
|
||||
|
||||
- Local: If the scan or clear commands are used on a library that does not
|
||||
exist, exit with an error. (Fixes: :issue:`1298`)
|
||||
|
||||
- MPD: Notify idling clients when a seek is performed. (Fixes: :issue:`1331`)
|
||||
|
||||
- MPD: Don't return tracks with empty URIs. (Partly fixes: :issue:`1340`, PR:
|
||||
:issue:`1343`)
|
||||
|
||||
- MPD: Add ``volume`` command that was reintroduced, though still as a
|
||||
deprecated command, in MPD 0.18 and is in use by some clients like mpc.
|
||||
(Fixes: :issue:`1393`, PR: :issue:`1397`)
|
||||
|
||||
- Proxy: Handle case where :confval:`proxy/port` is either missing from config
|
||||
or set to an empty string. (PR: :issue:`1371`)
|
||||
|
||||
|
||||
v1.1.1 (2015-09-14)
|
||||
===================
|
||||
|
||||
@ -93,14 +93,14 @@ source_suffix = '.rst'
|
||||
master_doc = 'index'
|
||||
|
||||
project = 'Mopidy'
|
||||
copyright = '2009-2015, Stein Magnus Jodal and contributors'
|
||||
copyright = '2009-2016, Stein Magnus Jodal and contributors'
|
||||
|
||||
from mopidy.internal.versioning import get_version
|
||||
release = get_version()
|
||||
version = '.'.join(release.split('.')[:2])
|
||||
|
||||
# To make the build reproducible, avoid using today's date in the manpages
|
||||
today = '2015'
|
||||
today = '2016'
|
||||
|
||||
exclude_trees = ['_build']
|
||||
|
||||
|
||||
129
docs/debian.rst
129
docs/debian.rst
@ -1,129 +0,0 @@
|
||||
.. _debian:
|
||||
|
||||
***************
|
||||
Debian packages
|
||||
***************
|
||||
|
||||
The Mopidy Debian package, ``mopidy``, is available from `apt.mopidy.com
|
||||
<http://apt.mopidy.com/>`__ as well as from Debian, Ubuntu and other
|
||||
Debian-based Linux distributions.
|
||||
|
||||
Some extensions are also available from all of these sources, while others,
|
||||
like Mopidy-Spotify and its dependencies, are only available from
|
||||
apt.mopidy.com. This may either be temporary until the package is uploaded to
|
||||
Debian and with time propagates to the other distributions. It may also be more
|
||||
long term, like in the Mopidy-Spotify case where there is uncertainities around
|
||||
licensing and distribution of non-free packages.
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
See :ref:`debian-install`.
|
||||
|
||||
|
||||
Running as a system service
|
||||
===========================
|
||||
|
||||
The Debian package comes with an init script. It starts Mopidy as a system
|
||||
service running as the ``mopidy`` user, which is created by the package.
|
||||
|
||||
The Debian package version 0.18.3-1 and older starts Mopidy as a system
|
||||
service by default. Version 0.18.3-2 and newer asks if you want to run Mopidy
|
||||
as a system service, defaulting to not doing so.
|
||||
|
||||
If you're running 0.18.3-2 or newer, and you've changed your mind about whether
|
||||
or not to run Mopidy as a system service, just run the following command to
|
||||
reconfigure the package::
|
||||
|
||||
sudo dpkg-reconfigure mopidy
|
||||
|
||||
If you're running 0.18.3-1 or older, and don't want to use the init script to
|
||||
run Mopidy as a system service, but instead just run Mopidy manually using your
|
||||
own user, you need to disable the init script and stop Mopidy by running::
|
||||
|
||||
sudo update-rc.d mopidy disable
|
||||
sudo service mopidy stop
|
||||
|
||||
This way of disabling the system service is compatible with the improved
|
||||
0.18.3-2 or newer version of the Debian package, so if you later upgrade to a
|
||||
newer version, you can change your mind using the ``dpkg-reconfigure`` command
|
||||
above.
|
||||
|
||||
|
||||
Differences when running as a system service
|
||||
============================================
|
||||
|
||||
If you want to run Mopidy using the init script, there's a few differences
|
||||
from a regular Mopidy setup you'll want to know about.
|
||||
|
||||
- All configuration is in :file:`/etc/mopidy`, not in your user's home
|
||||
directory. The main configuration file is :file:`/etc/mopidy/mopidy.conf`.
|
||||
You can do all your changes in this file.
|
||||
|
||||
- Mopidy extensions installed from Debian packages will sometimes install
|
||||
additional configuration files in :file:`/usr/share/mopidy/conf.d/`. These
|
||||
files just provide different defaults for the extension when run as a system
|
||||
service. You can override anything from :file:`/usr/share/mopidy/conf.d/` in
|
||||
the :file:`/etc/mopidy/mopidy.conf` configuration file.
|
||||
|
||||
Previously, the extension's default config was installed in
|
||||
:file:`/etc/mopidy/extensions.d/`. This was removed with the Debian
|
||||
package mopidy 0.19.4-3. If you have modified any files in
|
||||
:file:`/etc/mopidy/extensions.d/`, you should redo your modifications in
|
||||
:file:`/etc/mopidy/mopidy.conf` and delete the
|
||||
:file:`/etc/mopidy/extensions.d/` directory.
|
||||
|
||||
- The init script runs Mopidy as the ``mopidy`` user. The ``mopidy`` user will
|
||||
need read access to any local music you want Mopidy to play.
|
||||
|
||||
- To run Mopidy subcommands with the same user and config files as the init
|
||||
script uses, you can use ``sudo mopidyctl <subcommand>``. In other words,
|
||||
where you'll usually run::
|
||||
|
||||
mopidy config
|
||||
|
||||
You should instead run the following to inspect the system service's
|
||||
configuration::
|
||||
|
||||
sudo mopidyctl config
|
||||
|
||||
The same applies to scanning your local music collection. Where you'll
|
||||
normally run::
|
||||
|
||||
mopidy local scan
|
||||
|
||||
You should instead run::
|
||||
|
||||
sudo mopidyctl local scan
|
||||
|
||||
Previously, you used ``sudo service mopidy run <subcommand>`` instead of
|
||||
``mopidyctl``. This was deprecated in Debian package version 0.19.4-3 in
|
||||
favor of ``mopidyctl``, which also work for systems using systemd instead of
|
||||
sysvinit and traditional init scripts.
|
||||
|
||||
- Mopidy is started, stopped, and restarted just like any other system
|
||||
service::
|
||||
|
||||
sudo service mopidy start
|
||||
sudo service mopidy stop
|
||||
sudo service mopidy restart
|
||||
|
||||
- You can check if Mopidy is currently running as a system service by running::
|
||||
|
||||
sudo service mopidy status
|
||||
|
||||
- Mopidy installed from a Debian package can use Mopidy extensions installed
|
||||
both from Debian packages and with pip. This has always been the case.
|
||||
|
||||
Mopidy installed with pip can use extensions installed with pip, but
|
||||
not extensions installed from a Debian package released before August 2015.
|
||||
This is because the Debian packages used to install extensions into
|
||||
:file:`/usr/share/mopidy` which is normally not on your ``PYTHONPATH``.
|
||||
Thus, your pip-installed Mopidy would not find the Debian package-installed
|
||||
extensions.
|
||||
|
||||
In August 2015, all Mopidy extension Debian packages was modified to install
|
||||
into :file:`/usr/lib/python2.7/dist-packages`, like any other Python Debian
|
||||
package. Thus, Mopidy installed with pip can now use extensions installed
|
||||
from Debian.
|
||||
@ -81,8 +81,8 @@ announcements related to Mopidy and Mopidy extensions.
|
||||
installation/index
|
||||
config
|
||||
running
|
||||
service
|
||||
troubleshooting
|
||||
debian
|
||||
|
||||
|
||||
.. _ext:
|
||||
|
||||
@ -16,7 +16,8 @@ If you are running Arch Linux, you can install Mopidy using the
|
||||
pacman -Syu
|
||||
|
||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||
then you're ready to :doc:`run Mopidy </running>`.
|
||||
then you're ready to :doc:`run Mopidy </running>` or run Mopidy as a
|
||||
:ref:`service <service>`.
|
||||
|
||||
|
||||
Installing extensions
|
||||
|
||||
@ -48,12 +48,9 @@ and armhf (compatible with Raspberry Pi 1 and 2).
|
||||
sudo apt-get update
|
||||
sudo apt-get install mopidy
|
||||
|
||||
#. Before continuing, make sure you've read the :ref:`debian` section to learn
|
||||
about the differences between running Mopidy as a system service and
|
||||
manually as your own system user.
|
||||
|
||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and then
|
||||
you're ready to :doc:`run Mopidy </running>`.
|
||||
you're ready to :doc:`run Mopidy </running>` or run Mopidy as a
|
||||
:ref:`service <service>`.
|
||||
|
||||
When a new release of Mopidy is out, and you can't wait for you system to
|
||||
figure it out for itself, run the following to upgrade right away::
|
||||
@ -87,44 +84,3 @@ about any other requirements needed for the extension to work properly.
|
||||
|
||||
For a full list of available Mopidy extensions, including those not
|
||||
installable from apt.mopidy.com, see :ref:`ext`.
|
||||
|
||||
|
||||
Missing extensions
|
||||
==================
|
||||
|
||||
If you've installed a Mopidy extension with pip, restarted Mopidy, and Mopidy
|
||||
doesn't find the extension, there's probably a simple explanation and solution.
|
||||
|
||||
Mopidy installed with APT can detect and use Mopidy extensions installed with
|
||||
both APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`.
|
||||
|
||||
Mopidy installed with pip can only detect Mopidy extensions installed with pip.
|
||||
pip usually installs Mopidy as :file:`/usr/local/bin/mopidy`.
|
||||
|
||||
If you have Mopidy installed from both APT and pip, then the pip-installed
|
||||
Mopidy will probably shadow the APT-installed Mopidy because
|
||||
:file:`/usr/local/bin` usually has precedence over :file:`/usr/bin` in the
|
||||
``PATH`` environment variable. To check if this is the case on your system, you
|
||||
can use ``which`` to see what installation of Mopidy you use when you run
|
||||
``mopidy`` in your shell::
|
||||
|
||||
$ which mopidy
|
||||
/usr/local/bin/mopidy
|
||||
|
||||
If this is the case on your system, the recommended solution is to check that
|
||||
you have Mopidy installed from APT too::
|
||||
|
||||
$ /usr/bin/mopidy --version
|
||||
Mopidy 0.19.5
|
||||
|
||||
And then uninstall the pip-installed Mopidy::
|
||||
|
||||
sudo pip uninstall mopidy
|
||||
|
||||
Depending on what shell you use, the shell may still try to use
|
||||
:file:`/usr/local/bin/mopidy` even if it no longer exists. Check again with
|
||||
``which mopidy`` what your shell believes is the right ``mopidy`` executable to
|
||||
run. If the shell is still confused, you may need to restart it, or in the case
|
||||
of zsh, run ``rehash`` to update the shell.
|
||||
|
||||
For more details on why this works this way, see :ref:`debian`.
|
||||
|
||||
@ -86,6 +86,8 @@ For a full list of available Mopidy extensions, including those not installable
|
||||
from Homebrew, see :ref:`ext`.
|
||||
|
||||
|
||||
.. _osx-service:
|
||||
|
||||
Running Mopidy automatically on login
|
||||
=====================================
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@ -1,75 +1,68 @@
|
||||
.. _raspberrypi-installation:
|
||||
|
||||
*************************************
|
||||
Raspberry Pi: Mopidy on a credit card
|
||||
*************************************
|
||||
************
|
||||
Raspberry Pi
|
||||
************
|
||||
|
||||
Mopidy runs nicely on a `Raspberry Pi <http://www.raspberrypi.org/>`_. As of
|
||||
January 2013, Mopidy will run with Spotify support on both the armel
|
||||
(soft-float) and armhf (hard-float) architectures, which includes the Raspbian
|
||||
distribution.
|
||||
Mopidy runs on all versions of `Raspberry Pi <http://www.raspberrypi.org/>`_.
|
||||
However, note that Raspberry Pi 2 B's CPU is approximately six times as
|
||||
powerful as Raspberry Pi 1 and Raspberry Pi Zero, so Mopidy will be more joyful
|
||||
to use on a Raspberry Pi 2.
|
||||
|
||||
.. image:: raspberry-pi-by-jwrodgers.jpg
|
||||
.. image:: raspberrypi2.jpg
|
||||
:width: 640
|
||||
:height: 427
|
||||
:height: 363
|
||||
|
||||
|
||||
.. _raspi-wheezy:
|
||||
|
||||
How to for Raspbian "wheezy" and Debian "wheezy"
|
||||
================================================
|
||||
How to for Raspbian Jessie
|
||||
==========================
|
||||
|
||||
This guide applies for both:
|
||||
#. Download the latest Jessie or Jessie Lite disk image from
|
||||
http://www.raspberrypi.org/downloads/raspbian/.
|
||||
|
||||
- Raspbian "wheezy" for armhf (hard-float), and
|
||||
- Debian "wheezy" for armel (soft-float)
|
||||
If you're only using your Pi for Mopidy, go with Jessie Lite as you won't
|
||||
need the full graphical desktop included in the Jessie image.
|
||||
|
||||
If you don't know which one to select, go for the armhf variant, as it'll give
|
||||
you a lot better performance.
|
||||
#. Flash the Raspbian image you downloaded to your SD card.
|
||||
|
||||
#. Download the latest "wheezy" disk image from
|
||||
http://www.raspberrypi.org/downloads/. This was last tested with the images
|
||||
from 2013-05-25 for armhf and 2013-05-29 for armel.
|
||||
See the `Raspberry Pi installation docs
|
||||
<https://www.raspberrypi.org/documentation/installation/installing-images/README.md>`_
|
||||
for instructions.
|
||||
|
||||
#. Flash the OS image to your SD card. See
|
||||
http://elinux.org/RPi_Easy_SD_Card_Setup for help.
|
||||
#. If you connect a monitor and a keyboard, you'll see that the Pi boots right
|
||||
into the ``raspi-config`` tool.
|
||||
|
||||
#. If you have an SD card that's >2 GB, you don't have to resize the file
|
||||
systems on another computer. Just boot up your Raspberry Pi with the
|
||||
unaltered partions, and it will boot right into the ``raspi-config`` tool,
|
||||
which will let you grow the root file system to fill the SD card. This tool
|
||||
will also allow you do other useful stuff, like turning on the SSH server.
|
||||
If you boot with only a network cable connected, you'll have to find the IP
|
||||
address of the Pi yourself, e.g. by looking in the client list on your
|
||||
router/DHCP server. When you have found the Pi's IP address, you can SSH to
|
||||
the IP address and login with the user ``pi`` and password ``raspberry``.
|
||||
Once logged in, run ``sudo raspi-config`` to start the config tool as the
|
||||
``root`` user.
|
||||
|
||||
#. You can login to the default user using username ``pi`` and password
|
||||
``raspberry``. To become root, just enter ``sudo -i``.
|
||||
#. Use the ``raspi-config`` tool to setup the basics of your Pi. You might want
|
||||
to do one or more of the following:
|
||||
|
||||
#. To avoid a couple of potential problems with Mopidy, turn on IPv6 support:
|
||||
- Expand the file system to fill the SD card.
|
||||
- Change the password of the ``pi`` user.
|
||||
- Change the time zone.
|
||||
|
||||
- Load the IPv6 kernel module now::
|
||||
Under "Advanced Options":
|
||||
|
||||
sudo modprobe ipv6
|
||||
- Set a hostname.
|
||||
- Enable SSH if not already enabled.
|
||||
- If your will use HDMI for display and 3.5mm jack for audio, force the
|
||||
audio output to the 3.5mm jack. By default it will use HDMI for audio
|
||||
output if an HDMI cable is connected and the 3.5mm jack if not.
|
||||
|
||||
- Add ``ipv6`` to ``/etc/modules`` to ensure the IPv6 kernel module is
|
||||
loaded on boot::
|
||||
Once done, select "Finish" and restart your Pi.
|
||||
|
||||
echo ipv6 | sudo tee -a /etc/modules
|
||||
If you want to change any settings later, you can simply rerun ``sudo
|
||||
raspi-config``.
|
||||
|
||||
#. Since I have a HDMI cable connected, but want the sound on the analog sound
|
||||
connector, I have to run::
|
||||
|
||||
sudo amixer cset numid=3 1
|
||||
|
||||
to force it to use analog output. ``1`` means analog, ``0`` means auto, and
|
||||
is the default, while ``2`` means HDMI. You can test sound output
|
||||
independent of Mopidy by running::
|
||||
|
||||
aplay /usr/share/sounds/alsa/Front_Center.wav
|
||||
|
||||
If you hear a voice saying "Front Center", then your sound is working.
|
||||
|
||||
To make the change to analog output stick, you can add the ``amixer``
|
||||
command to e.g. ``/etc/rc.local``, which will be executed when the system is
|
||||
booting.
|
||||
#. Once you've rebooted and has logged in as the ``pi`` user, you can enter
|
||||
``sudo -i`` to become ``root``.
|
||||
|
||||
#. Install Mopidy and its dependencies as described in :ref:`debian-install`.
|
||||
|
||||
@ -79,114 +72,19 @@ you a lot better performance.
|
||||
starting at boot.
|
||||
|
||||
|
||||
Appendix A: Fixing audio quality issues
|
||||
=======================================
|
||||
Testing sound output
|
||||
====================
|
||||
|
||||
As of about April 2013 the following steps should resolve any audio
|
||||
issues for HDMI and analog without the use of an external USB sound
|
||||
card.
|
||||
You can test sound output independent of Mopidy by running::
|
||||
|
||||
#. Ensure your system is up to date. On Debian based systems run::
|
||||
aplay /usr/share/sounds/alsa/Front_Center.wav
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get dist-upgrade
|
||||
If you hear a voice saying "Front Center", then your sound is working.
|
||||
|
||||
#. Ensure you have a new enough firmware. On Debian based systems
|
||||
`rpi-update <https://github.com/Hexxeh/rpi-update>`_
|
||||
can be used.
|
||||
If you want to change your audio output setting, simply rerun ``sudo
|
||||
raspi-config``. Alternatively, you can change the audio output setting
|
||||
directly by running:
|
||||
|
||||
#. Update either ``~/.asoundrc`` or ``/etc/asound.conf`` to the
|
||||
following::
|
||||
|
||||
pcm.!default {
|
||||
type hw
|
||||
card 0
|
||||
}
|
||||
ctl.!default {
|
||||
type hw
|
||||
card 0
|
||||
}
|
||||
|
||||
Note that if you have an ``~/.asoundrc`` it will overide any global
|
||||
settings from ``/etc/asound.conf``.
|
||||
|
||||
#. For Mopidy to output audio directly to ALSA, instead of Jack which
|
||||
GStreamer usually defaults to on Raspberry Pi, install the
|
||||
``gstreamer0.10-alsa`` package::
|
||||
|
||||
sudo apt-get install gstreamer0.10-alsa
|
||||
|
||||
Then update your ``~/.config/mopidy/mopidy.conf`` to contain::
|
||||
|
||||
[audio]
|
||||
output = alsasink
|
||||
|
||||
Following these steps you should be able to get crackle free sound on either
|
||||
HDMI or analog. Note that you might need to ensure that PulseAudio is no longer
|
||||
running to get this working nicely.
|
||||
|
||||
This recipe has been confirmed as working by a number of users on our issue
|
||||
tracker and IRC. As a reference, the following versions where used for testing
|
||||
this, however all newer and some older version are likely to work as we have
|
||||
not determined the exact revision that fixed this::
|
||||
|
||||
$ uname -a
|
||||
Linux raspberrypi 3.6.11+ #408 PREEMPT Wed Apr 10 20:33:39 BST 2013 armv6l GNU/Linux
|
||||
|
||||
$ /opt/vc/bin/vcgencmd version
|
||||
Apr 25 2013 01:07:36
|
||||
Copyright (c) 2012 Broadcom
|
||||
version 386589 (release)
|
||||
|
||||
The only remaining known issue is a slight gap in playback at track changes
|
||||
this is likely due to gapless playback not being implemented and is being
|
||||
worked on irrespective of Raspberry Pi related work.
|
||||
|
||||
|
||||
Appendix B: Raspbmc not booting
|
||||
===============================
|
||||
|
||||
Due to a dependency version problem where XBMC uses another version of libtag
|
||||
than what Debian originally ships with, you might have to make some minor
|
||||
changes for Raspbmc to start properly after installing Mopidy.
|
||||
|
||||
If you notice that XBMC is not starting but gets stuck in a loop,
|
||||
you need to make the following changes::
|
||||
|
||||
sudo ln -sf /home/pi/.xbmc-current/xbmc-bin/lib/xbmc/system/libtag.so.1 \
|
||||
/usr/lib/arm-linux-gnueabihf/libtag.so.1
|
||||
|
||||
However, this will not persist the changes. To persist the changes edit
|
||||
:file:`/etc/ld.so.conf.d/arm-linux-gnueabihf.conf` and add the following at the
|
||||
top::
|
||||
|
||||
/home/pi/.xbmc-current/xbmc-bin/lib/xbmc/system
|
||||
|
||||
It's very important to add it at the top of the file as this indicates the
|
||||
priority of the folder in which to look for shared libraries.
|
||||
|
||||
XBMC doesn't play nicely with the system wide installed version of libtag that
|
||||
got installed together with Mopidy, but rather vendors in its own version.
|
||||
|
||||
More info about this issue can be found in `this post
|
||||
<http://geeks.noeit.com/xbmc-library-dependency-error/>`_.
|
||||
|
||||
Please note that if you're running Xbian or another XBMC distribution these
|
||||
instructions might vary for your system.
|
||||
|
||||
|
||||
Appendix C: Installation on XBian
|
||||
=================================
|
||||
|
||||
Similar to the Raspbmc issue outlined in Appendix B, it's not possible to
|
||||
install Mopidy on XBian without first resolving a dependency problem between
|
||||
``gstreamer0.10-plugins-good`` and ``libtag1c2a``. More information can be
|
||||
found in `this post
|
||||
<https://github.com/xbianonpi/xbian/issues/378#issuecomment-37723392>`_.
|
||||
|
||||
Run the following commands to remedy this and then install Mopidy as normal::
|
||||
|
||||
cd /tmp
|
||||
wget http://apt.xbian.org/pool/stable/rpi-wheezy/l/libtag1c2a/libtag1c2a_1.7.2-1_armhf.deb
|
||||
sudo dpkg -i libtag1c2a_1.7.2-1_armhf.deb
|
||||
rm libtag1c2a_1.7.2-1_armhf.deb
|
||||
- Auto (HDMI if connected, else 3.5mm jack): ``sudo amixer cset numid=3 0``
|
||||
- Use 3.5mm jack: ``sudo amixer cset numid=3 1``
|
||||
- Use HDMI: ``sudo amixer cset numid=3 2``
|
||||
|
||||
BIN
docs/installation/raspberrypi2.jpg
Normal file
BIN
docs/installation/raspberrypi2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@ -39,21 +39,8 @@ using ``pkill``::
|
||||
pkill mopidy
|
||||
|
||||
|
||||
Init scripts
|
||||
============
|
||||
Running as a service
|
||||
====================
|
||||
|
||||
- The ``mopidy`` package at `apt.mopidy.com <http://apt.mopidy.com/>`__ comes
|
||||
with an `sysvinit init script
|
||||
<https://github.com/mopidy/mopidy/blob/debian/debian/mopidy.init>`_. For
|
||||
more details, see the :ref:`debian` section of the docs.
|
||||
|
||||
- The ``mopidy`` package in `Arch Linux
|
||||
<https://www.archlinux.org/packages/community/any/mopidy/>`__ comes with a systemd init
|
||||
script.
|
||||
|
||||
- A blog post by Benjamin Guillet explains how to `Daemonize Mopidy and Launch
|
||||
It at Login on OS X
|
||||
<http://www.benjaminguillet.com/blog/2013/08/16/launch-mopidy-at-login-on-os-x/>`_.
|
||||
|
||||
- Issue :issue:`266` contains a bunch of init scripts for Mopidy, including
|
||||
Upstart init scripts.
|
||||
Once you're done exploring Mopidy and want to run it as a proper service, check
|
||||
out :ref:`service`.
|
||||
|
||||
98
docs/service.rst
Normal file
98
docs/service.rst
Normal file
@ -0,0 +1,98 @@
|
||||
.. _service:
|
||||
|
||||
********************
|
||||
Running as a service
|
||||
********************
|
||||
|
||||
If you want to run Mopidy as a service using either an init script or a systemd
|
||||
service, there's a few differences from running Mopidy as your own user you'll
|
||||
want to know about. The following applies to Debian, Ubuntu, Raspbian, and
|
||||
Arch. Hopefully, other distributions packaging Mopidy will make sure this works
|
||||
the same way on their distribution.
|
||||
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
All configuration is in :file:`/etc/mopidy`, not in your user's home directory.
|
||||
|
||||
The main configuration file is :file:`/etc/mopidy/mopidy.conf`. If there are
|
||||
more than one configuration file, this is the configuration file with the
|
||||
highest priority, so it can override configs from all other config files.
|
||||
Thus, you can do all your changes in this file.
|
||||
|
||||
|
||||
mopidy user
|
||||
===========
|
||||
|
||||
The init script runs Mopidy as the ``mopidy`` user, which is automatically
|
||||
created when you install the Mopidy package. The ``mopidy`` user will need read
|
||||
access to any local music you want Mopidy to play.
|
||||
|
||||
|
||||
Subcommands
|
||||
===========
|
||||
|
||||
To run Mopidy subcommands with the same user and config files as the service
|
||||
uses, you can use ``sudo mopidyctl <subcommand>``. In other words, where you'll
|
||||
usually run::
|
||||
|
||||
mopidy config
|
||||
|
||||
You should instead run the following to inspect the service's configuration::
|
||||
|
||||
sudo mopidyctl config
|
||||
|
||||
The same applies to scanning your local music collection. Where you'll normally
|
||||
run::
|
||||
|
||||
mopidy local scan
|
||||
|
||||
You should instead run::
|
||||
|
||||
sudo mopidyctl local scan
|
||||
|
||||
|
||||
Service management with systemd
|
||||
===============================
|
||||
|
||||
On modern systems using systemd you can enable the Mopidy service by running::
|
||||
|
||||
sudo systemctl enable mopidy
|
||||
|
||||
This will make Mopidy start when the system boots.
|
||||
|
||||
Mopidy is started, stopped, and restarted just like any other systemd service::
|
||||
|
||||
sudo systemctl start mopidy
|
||||
sudo systemctl stop mopidy
|
||||
sudo systemctl restart mopidy
|
||||
|
||||
You can check if Mopidy is currently running as a service by running::
|
||||
|
||||
sudo systemctl status mopidy
|
||||
|
||||
|
||||
Service management on Debian
|
||||
============================
|
||||
|
||||
On Debian systems (both those using systemd and not) you can enable the Mopidy
|
||||
service by running::
|
||||
|
||||
sudo dpkg-reconfigure mopidy
|
||||
|
||||
Mopidy can be started, stopped, and restarted using the ``service`` command::
|
||||
|
||||
sudo service mopidy start
|
||||
sudo service mopidy stop
|
||||
sudo service mopidy restart
|
||||
|
||||
You can check if Mopidy is currently running as a service by running::
|
||||
|
||||
sudo service mopidy status
|
||||
|
||||
|
||||
Service on OS X
|
||||
===============
|
||||
|
||||
If you're installing Mopidy on OS X, see :ref:`osx-service`.
|
||||
@ -14,4 +14,4 @@ if not (2, 7) <= sys.version_info < (3,):
|
||||
warnings.filterwarnings('ignore', 'could not open display')
|
||||
|
||||
|
||||
__version__ = '1.1.1'
|
||||
__version__ = '1.1.2'
|
||||
|
||||
@ -141,8 +141,9 @@ def _process(pipeline, timeout_ms):
|
||||
have_audio = False
|
||||
missing_message = None
|
||||
|
||||
types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR
|
||||
| gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG)
|
||||
types = (
|
||||
gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR |
|
||||
gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG)
|
||||
|
||||
previous = clock.get_time()
|
||||
while timeout > 0:
|
||||
|
||||
@ -236,7 +236,9 @@ class LibraryController(object):
|
||||
result = future.get()
|
||||
if result is not None:
|
||||
validation.check_instances(result, models.Track)
|
||||
results[u] = result
|
||||
# TODO Consider making Track.uri field mandatory, and
|
||||
# then remove this filtering of tracks without URIs.
|
||||
results[u] = [r for r in result if r.uri]
|
||||
|
||||
if uri:
|
||||
return results[uri]
|
||||
|
||||
@ -31,7 +31,8 @@ class CoreListener(listener.Listener):
|
||||
:type event: string
|
||||
:param kwargs: any other arguments to the specific event handlers
|
||||
"""
|
||||
getattr(self, event)(**kwargs)
|
||||
# Just delegate to parent, entry mostly for docs.
|
||||
super(CoreListener, self).on_event(event, **kwargs)
|
||||
|
||||
def track_playback_paused(self, tl_track, time_position):
|
||||
"""
|
||||
|
||||
@ -207,13 +207,24 @@ class PlaybackController(object):
|
||||
if old_state == PlaybackState.PLAYING:
|
||||
self._play(on_error_step=on_error_step)
|
||||
elif old_state == PlaybackState.PAUSED:
|
||||
# NOTE: this is just a quick hack to fix #1177 as this code has
|
||||
# already been killed in the gapless branch.
|
||||
# NOTE: this is just a quick hack to fix #1177, #1352, and #1378
|
||||
# as this code has already been killed in the gapless branch.
|
||||
backend = self._get_backend()
|
||||
if backend:
|
||||
backend.playback.prepare_change()
|
||||
backend.playback.change_track(tl_track.track).get()
|
||||
self.pause()
|
||||
success = backend.playback.change_track(tl_track.track).get()
|
||||
if success:
|
||||
self.core.tracklist._mark_playing(tl_track)
|
||||
self.core.history._add_track(tl_track.track)
|
||||
else:
|
||||
self.core.tracklist._mark_unplayable(tl_track)
|
||||
if on_error_step == 1:
|
||||
# TODO: can cause an endless loop for single track
|
||||
# repeat.
|
||||
self.next()
|
||||
elif on_error_step == -1:
|
||||
self.previous()
|
||||
self.pause()
|
||||
|
||||
# TODO: this is not really end of track, this is on_need_next_track
|
||||
def _on_end_of_track(self):
|
||||
|
||||
@ -54,7 +54,7 @@ class Extension(object):
|
||||
def get_config_schema(self):
|
||||
"""The extension's config validation schema
|
||||
|
||||
:returns: :class:`~mopidy.config.schema.ExtensionConfigSchema`
|
||||
:returns: :class:`~mopidy.config.schemas.ConfigSchema`
|
||||
"""
|
||||
schema = config_lib.ConfigSchema(self.ext_name)
|
||||
schema['enabled'] = config_lib.Boolean()
|
||||
|
||||
@ -21,8 +21,8 @@ def format_proxy(proxy_config, auth=True):
|
||||
if not proxy_config.get('hostname'):
|
||||
return None
|
||||
|
||||
port = proxy_config.get('port', 80)
|
||||
if port < 0:
|
||||
port = proxy_config.get('port')
|
||||
if not port or port < 0:
|
||||
port = 80
|
||||
|
||||
if proxy_config.get('username') and proxy_config.get('password') and auth:
|
||||
|
||||
@ -29,7 +29,11 @@ def download(session, uri, timeout=1.0, chunk_size=4096):
|
||||
'%.3fs', uri, timeout)
|
||||
return None
|
||||
except requests.exceptions.InvalidSchema:
|
||||
logger.warning('%s has an unsupported schema.', uri)
|
||||
logger.warning('Download of %r failed due to unsupported schema', uri)
|
||||
return None
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logger.warning('Download of %r failed: %s', uri, exc)
|
||||
logger.debug('Download exception details', exc_info=True)
|
||||
return None
|
||||
|
||||
content = []
|
||||
|
||||
@ -19,6 +19,8 @@ LOG_LEVELS = {
|
||||
TRACE_LOG_LEVEL = 5
|
||||
logging.addLevelName(TRACE_LOG_LEVEL, 'TRACE')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DelayedHandler(logging.Handler):
|
||||
|
||||
@ -54,8 +56,12 @@ def setup_logging(config, verbosity_level, save_debug_log):
|
||||
if config['logging']['config_file']:
|
||||
# Logging config from file must be read before other handlers are
|
||||
# added. If not, the other handlers will have no effect.
|
||||
logging.config.fileConfig(config['logging']['config_file'],
|
||||
disable_existing_loggers=False)
|
||||
try:
|
||||
path = config['logging']['config_file']
|
||||
logging.config.fileConfig(path, disable_existing_loggers=False)
|
||||
except Exception as e:
|
||||
# Catch everything as logging does not specify what can go wrong.
|
||||
logger.error('Loading logging config %r failed. %s', path, e)
|
||||
|
||||
setup_console_logging(config, verbosity_level)
|
||||
if save_debug_log:
|
||||
|
||||
@ -51,4 +51,8 @@ class Listener(object):
|
||||
:type event: string
|
||||
:param kwargs: any other arguments to the specific event handlers
|
||||
"""
|
||||
getattr(self, event)(**kwargs)
|
||||
try:
|
||||
getattr(self, event)(**kwargs)
|
||||
except Exception:
|
||||
# Ensure we don't crash the actor due to "bad" events.
|
||||
logger.exception('Triggering event failed: %s', event)
|
||||
|
||||
@ -21,8 +21,8 @@ def _get_library(args, config):
|
||||
library_name = config['local']['library']
|
||||
|
||||
if library_name not in libraries:
|
||||
logger.warning('Local library %s not found', library_name)
|
||||
return 1
|
||||
logger.error('Local library %s not found', library_name)
|
||||
return None
|
||||
|
||||
logger.debug('Using %s as the local library', library_name)
|
||||
return libraries[library_name](config)
|
||||
@ -41,6 +41,9 @@ class ClearCommand(commands.Command):
|
||||
|
||||
def run(self, args, config):
|
||||
library = _get_library(args, config)
|
||||
if library is None:
|
||||
return 1
|
||||
|
||||
prompt = '\nAre you sure you want to clear the library? [y/N] '
|
||||
|
||||
if compat.input(prompt).lower() != 'y':
|
||||
@ -76,6 +79,8 @@ class ScanCommand(commands.Command):
|
||||
bytes(file_ext.lower()) for file_ext in excluded_file_extensions)
|
||||
|
||||
library = _get_library(args, config)
|
||||
if library is None:
|
||||
return 1
|
||||
|
||||
file_mtimes, file_errors = path.find_mtimes(
|
||||
media_dir, follow=config['local']['scan_follow_symlinks'])
|
||||
|
||||
@ -348,14 +348,14 @@ class SearchResult(ValidatedImmutableObject):
|
||||
:type albums: list of :class:`Album` elements
|
||||
"""
|
||||
|
||||
# The search result URI. Read-only.
|
||||
#: The search result URI. Read-only.
|
||||
uri = fields.URI()
|
||||
|
||||
# The tracks matching the search query. Read-only.
|
||||
#: The tracks matching the search query. Read-only.
|
||||
tracks = fields.Collection(type=Track, container=tuple)
|
||||
|
||||
# The artists matching the search query. Read-only.
|
||||
#: The artists matching the search query. Read-only.
|
||||
artists = fields.Collection(type=Artist, container=tuple)
|
||||
|
||||
# The albums matching the search query. Read-only.
|
||||
#: The albums matching the search query. Read-only.
|
||||
albums = fields.Collection(type=Album, container=tuple)
|
||||
|
||||
@ -77,3 +77,6 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||
|
||||
def stream_title_changed(self, title):
|
||||
self.send_idle('playlist')
|
||||
|
||||
def seeked(self, time_position):
|
||||
self.send_idle('player')
|
||||
|
||||
@ -47,6 +47,7 @@ class MpdDispatcher(object):
|
||||
return self._call_next_filter(request, response, filter_chain)
|
||||
|
||||
def handle_idle(self, subsystem):
|
||||
# TODO: validate against mopidy/mpd/protocol/status.SUBSYSTEMS
|
||||
self.context.events.add(subsystem)
|
||||
|
||||
subsystems = self.context.subscriptions.intersection(
|
||||
|
||||
@ -426,3 +426,27 @@ def stop(context):
|
||||
Stops playing.
|
||||
"""
|
||||
context.core.playback.stop()
|
||||
|
||||
|
||||
@protocol.commands.add('volume', change=protocol.INT)
|
||||
def volume(context, change):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
|
||||
``volume {CHANGE}``
|
||||
|
||||
Changes volume by amount ``CHANGE``.
|
||||
|
||||
Note: ``volume`` is deprecated, use ``setvol`` instead.
|
||||
"""
|
||||
if change < -100 or change > 100:
|
||||
raise exceptions.MpdArgError('Invalid volume value')
|
||||
|
||||
old_volume = context.core.mixer.get_volume().get()
|
||||
if old_volume is None:
|
||||
raise exceptions.MpdSystemError('problems setting volume')
|
||||
|
||||
new_volume = min(max(0, old_volume + change), 100)
|
||||
success = context.core.mixer.set_volume(new_volume).get()
|
||||
if not success:
|
||||
raise exceptions.MpdSystemError('problems setting volume')
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
|
||||
from mopidy.models import TlTrack
|
||||
from mopidy.mpd.protocol import tagtype_list
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# TODO: special handling of local:// uri scheme
|
||||
normalize_path_re = re.compile(r'[^/]+')
|
||||
|
||||
@ -34,8 +38,12 @@ def track_to_mpd_format(track, position=None, stream_title=None):
|
||||
else:
|
||||
(tlid, track) = (None, track)
|
||||
|
||||
if not track.uri:
|
||||
logger.warning('Ignoring track without uri')
|
||||
return []
|
||||
|
||||
result = [
|
||||
('file', track.uri or ''),
|
||||
('file', track.uri),
|
||||
('Time', track.length and (track.length // 1000) or 0),
|
||||
('Artist', concat_multi_values(track.artists, 'name')),
|
||||
('Album', track.album and track.album.name or ''),
|
||||
@ -164,7 +172,9 @@ def tracks_to_mpd_format(tracks, start=0, end=None):
|
||||
assert len(tracks) == len(positions)
|
||||
result = []
|
||||
for track, position in zip(tracks, positions):
|
||||
result.append(track_to_mpd_format(track, position))
|
||||
formatted_track = track_to_mpd_format(track, position)
|
||||
if formatted_track:
|
||||
result.append(formatted_track)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@ -123,12 +123,14 @@ def _unwrap_stream(uri, timeout, scanner, requests_session):
|
||||
logger.debug('GStreamer failed scanning URI (%s): %s', uri, exc)
|
||||
scan_result = None
|
||||
|
||||
if scan_result is not None and not (
|
||||
scan_result.mime.startswith('text/') or
|
||||
scan_result.mime.startswith('application/')):
|
||||
logger.debug(
|
||||
'Unwrapped potential %s stream: %s', scan_result.mime, uri)
|
||||
return uri
|
||||
if scan_result is not None:
|
||||
if scan_result.playable or (
|
||||
not scan_result.mime.startswith('text/') and
|
||||
not scan_result.mime.startswith('application/')
|
||||
):
|
||||
logger.debug(
|
||||
'Unwrapped potential %s stream: %s', scan_result.mime, uri)
|
||||
return uri
|
||||
|
||||
download_timeout = deadline - time.time()
|
||||
if download_timeout < 0:
|
||||
|
||||
@ -153,8 +153,8 @@ class CoreLibraryTest(BaseCoreLibraryTest):
|
||||
self.core.library.lookup('dummy1:a', ['dummy2:a'])
|
||||
|
||||
def test_lookup_can_handle_uris(self):
|
||||
track1 = Track(name='abc')
|
||||
track2 = Track(name='def')
|
||||
track1 = Track(uri='dummy1:a', name='abc')
|
||||
track2 = Track(uri='dummy2:a', name='def')
|
||||
|
||||
self.library1.lookup().get.return_value = [track1]
|
||||
self.library2.lookup().get.return_value = [track2]
|
||||
@ -169,6 +169,15 @@ class CoreLibraryTest(BaseCoreLibraryTest):
|
||||
self.assertFalse(self.library1.lookup.called)
|
||||
self.assertFalse(self.library2.lookup.called)
|
||||
|
||||
def test_lookup_ignores_tracks_without_uri_set(self):
|
||||
track1 = Track(uri='dummy1:a', name='abc')
|
||||
track2 = Track()
|
||||
|
||||
self.library1.lookup().get.return_value = [track1, track2]
|
||||
|
||||
result = self.core.library.lookup(uris=['dummy1:a'])
|
||||
self.assertEqual(result, {'dummy1:a': [track1]})
|
||||
|
||||
def test_refresh_with_uri_selects_dummy1_backend(self):
|
||||
self.core.library.refresh('dummy1:a')
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import pykka
|
||||
|
||||
from mopidy import backend, core
|
||||
from mopidy.internal import deprecation
|
||||
from mopidy.models import Track
|
||||
from mopidy.models import TlTrack, Track
|
||||
|
||||
from tests import dummy_audio as audio
|
||||
|
||||
@ -39,21 +39,34 @@ class CorePlaybackTest(unittest.TestCase):
|
||||
# A backend without the optional playback provider
|
||||
self.backend3 = mock.Mock()
|
||||
self.backend3.uri_schemes.get.return_value = ['dummy3']
|
||||
self.backend3.has_playback().get.return_value = False
|
||||
self.backend3.has_playback.return_value.get.return_value = False
|
||||
|
||||
# A backend for which 'change_track' fails
|
||||
self.backend4 = mock.Mock()
|
||||
self.backend4.uri_schemes.get.return_value = ['dummy4']
|
||||
self.playback4 = mock.Mock(spec=backend.PlaybackProvider)
|
||||
self.playback4.get_time_position.return_value.get.return_value = 1000
|
||||
future_mock = mock.Mock(spec=pykka.future.Future)
|
||||
future_mock.get.return_value = False
|
||||
self.playback4.change_track.return_value = future_mock
|
||||
self.backend4.playback = self.playback4
|
||||
|
||||
self.tracks = [
|
||||
Track(uri='dummy1:a', length=40000),
|
||||
Track(uri='dummy2:a', length=40000),
|
||||
Track(uri='dummy3:a', length=40000), # Unplayable
|
||||
Track(uri='dummy3:a', length=40000), # No playback provider
|
||||
Track(uri='dummy1:b', length=40000),
|
||||
Track(uri='dummy1:c', length=None), # No duration
|
||||
Track(uri='dummy4:a', length=40000), # Unplayable
|
||||
Track(uri='dummy1:d', length=40000),
|
||||
]
|
||||
|
||||
self.uris = [
|
||||
'dummy1:a', 'dummy2:a', 'dummy3:a', 'dummy1:b', 'dummy1:c']
|
||||
'dummy1:a', 'dummy2:a', 'dummy3:a', 'dummy1:b', 'dummy1:c',
|
||||
'dummy4:a', 'dummy1:d']
|
||||
|
||||
self.core = core.Core(config, mixer=None, backends=[
|
||||
self.backend1, self.backend2, self.backend3])
|
||||
self.backend1, self.backend2, self.backend3, self.backend4])
|
||||
|
||||
def lookup(uris):
|
||||
result = {uri: [] for uri in uris}
|
||||
@ -172,16 +185,40 @@ class CorePlaybackTest(unittest.TestCase):
|
||||
self.playback2.change_track.return_value.get.return_value = False
|
||||
|
||||
self.core.tracklist.clear()
|
||||
self.core.tracklist.add(uris=self.uris[:2])
|
||||
self.core.tracklist.add(uris=self.uris[-2:])
|
||||
tl_tracks = self.core.tracklist.tl_tracks
|
||||
|
||||
self.core.playback.play(tl_tracks[0])
|
||||
self.core.playback.play(tl_tracks[1])
|
||||
|
||||
# TODO: we really want to check that the track was marked unplayable
|
||||
# and that next was called. This is just an indirect way of checking
|
||||
# this :(
|
||||
self.assertEqual(self.core.playback.state, core.PlaybackState.STOPPED)
|
||||
assert self.core.playback.get_current_tl_track() == tl_tracks[1]
|
||||
|
||||
def test_pause_play_skips_to_next_on_unplayable_track(self):
|
||||
"""Checks that we handle backend.change_track failing."""
|
||||
self.playback2.change_track.return_value.get.return_value = False
|
||||
|
||||
self.core.tracklist.clear()
|
||||
self.core.tracklist.add(uris=self.uris[-3:])
|
||||
tl_tracks = self.core.tracklist.tl_tracks
|
||||
|
||||
self.core.playback.pause()
|
||||
self.core.playback._set_current_tl_track(tl_tracks[0])
|
||||
self.core.playback.next()
|
||||
self.core.playback.play(self.core.playback.get_current_tl_track())
|
||||
assert self.core.playback.get_current_tl_track() == tl_tracks[2]
|
||||
|
||||
def test_pause_resume_skips_to_next_on_unplayable_track(self):
|
||||
"""Checks that we handle backend.change_track failing."""
|
||||
self.playback2.change_track.return_value.get.return_value = False
|
||||
|
||||
self.core.tracklist.clear()
|
||||
self.core.tracklist.add(uris=self.uris[-3:])
|
||||
tl_tracks = self.core.tracklist.tl_tracks
|
||||
|
||||
self.core.playback.pause()
|
||||
self.core.playback._set_current_tl_track(tl_tracks[0])
|
||||
self.core.playback.next()
|
||||
self.core.playback.resume()
|
||||
assert self.core.playback.get_current_tl_track() == tl_tracks[2]
|
||||
|
||||
@mock.patch(
|
||||
'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener)
|
||||
@ -388,6 +425,23 @@ class CorePlaybackTest(unittest.TestCase):
|
||||
|
||||
self.assertNotIn(tl_track, self.core.tracklist.tl_tracks)
|
||||
|
||||
def test_next_in_consume_mode_removes_unplayable_track(self):
|
||||
self.backend1.playback.change_track = mock.PropertyMock()
|
||||
self.backend1.playback.change_track.return_value.get.return_value = (
|
||||
False)
|
||||
|
||||
self.backend2.playback.change_track = mock.PropertyMock()
|
||||
self.backend2.playback.change_track.return_value.get.return_value = (
|
||||
False)
|
||||
self.core.tracklist.set_consume(True)
|
||||
|
||||
self.core.playback.play(self.tl_tracks[0])
|
||||
self.core.playback.next()
|
||||
|
||||
tl_tracks = self.core.tracklist.get_tl_tracks()
|
||||
self.assertNotIn(self.tl_tracks[1], tl_tracks)
|
||||
self.assertNotIn(self.tl_tracks[2], tl_tracks)
|
||||
|
||||
@mock.patch(
|
||||
'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener)
|
||||
def test_next_emits_events(self, listener_mock):
|
||||
@ -789,3 +843,102 @@ class Bug1177RegressionTest(unittest.TestCase):
|
||||
c.playback.pause()
|
||||
c.playback.next()
|
||||
b.playback.change_track.assert_called_once_with(track2)
|
||||
|
||||
|
||||
class Bug1352RegressionTest(unittest.TestCase):
|
||||
def test(self):
|
||||
config = {
|
||||
'core': {
|
||||
'max_tracklist_length': 10000,
|
||||
}
|
||||
}
|
||||
|
||||
b = mock.Mock()
|
||||
b.uri_schemes.get.return_value = ['dummy']
|
||||
b.playback = mock.Mock(spec=backend.PlaybackProvider)
|
||||
b.playback.change_track.return_value.get.return_value = True
|
||||
b.playback.play.return_value.get.return_value = True
|
||||
|
||||
track1 = Track(uri='dummy:a', length=40000)
|
||||
track2 = Track(uri='dummy:b', length=40000)
|
||||
|
||||
tl_track2 = TlTrack(1, track2)
|
||||
|
||||
c = core.Core(config, mixer=None, backends=[b])
|
||||
c.tracklist.add([track1, track2])
|
||||
|
||||
c.history._add_track = mock.PropertyMock()
|
||||
c.tracklist._mark_playing = mock.PropertyMock()
|
||||
|
||||
c.playback.play()
|
||||
b.playback.change_track.reset_mock()
|
||||
c.history._add_track.reset_mock()
|
||||
c.tracklist._mark_playing.reset_mock()
|
||||
|
||||
c.playback.pause()
|
||||
c.playback.next()
|
||||
b.playback.change_track.assert_called_once_with(track2)
|
||||
c.history._add_track.assert_called_once_with(track2)
|
||||
c.tracklist._mark_playing.assert_called_once_with(tl_track2)
|
||||
|
||||
|
||||
class Bug1358RegressionTest(unittest.TestCase):
|
||||
|
||||
def setUp(self): # noqa: N802
|
||||
config = {
|
||||
'core': {
|
||||
'max_tracklist_length': 10000,
|
||||
}
|
||||
}
|
||||
|
||||
self.backend1 = mock.Mock()
|
||||
self.backend1.uri_schemes.get.return_value = ['dummy1']
|
||||
self.playback1 = mock.Mock(spec=backend.PlaybackProvider)
|
||||
self.backend1.playback.change_track.return_value.get.return_value = (
|
||||
False)
|
||||
self.backend1.playback = self.playback1
|
||||
|
||||
self.backend2 = mock.Mock()
|
||||
self.backend2.uri_schemes.get.return_value = ['dummy2']
|
||||
self.playback2 = mock.Mock(spec=backend.PlaybackProvider)
|
||||
self.backend1.playback.change_track.return_value.get.return_value = (
|
||||
False)
|
||||
self.backend2.playback = self.playback2
|
||||
|
||||
self.tracks = [
|
||||
Track(uri='dummy1:a', length=40000),
|
||||
Track(uri='dummy2:a', length=40000),
|
||||
]
|
||||
|
||||
self.uris = [t.uri for t in self.tracks]
|
||||
|
||||
self.core = core.Core(
|
||||
config, mixer=None, backends=[self.backend1, self.backend2])
|
||||
|
||||
def lookup(uris):
|
||||
result = {uri: [] for uri in uris}
|
||||
for track in self.tracks:
|
||||
if track.uri in result:
|
||||
result[track.uri].append(track)
|
||||
return result
|
||||
|
||||
self.lookup_patcher = mock.patch.object(self.core.library, 'lookup')
|
||||
self.lookup_mock = self.lookup_patcher.start()
|
||||
self.lookup_mock.side_effect = lookup
|
||||
|
||||
self.core.tracklist.add(uris=self.uris)
|
||||
|
||||
self.tl_tracks = self.core.tracklist.get_tl_tracks()
|
||||
|
||||
def tearDown(self): # noqa: N802
|
||||
self.lookup_patcher.stop()
|
||||
|
||||
def test_next_in_consume_mode_removes_unplayable_track(self):
|
||||
self.core.tracklist.set_consume(True)
|
||||
|
||||
self.core.playback.play(self.tl_tracks[0])
|
||||
self.core.playback.next()
|
||||
|
||||
tl_tracks = self.core.tracklist.get_tl_tracks()
|
||||
self.assertNotIn(self.tl_tracks[0], tl_tracks)
|
||||
self.assertNotIn(self.tl_tracks[1], tl_tracks)
|
||||
|
||||
@ -80,41 +80,6 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase):
|
||||
self.assertTrue(self.core.tracklist.repeat.get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_below_min(self):
|
||||
self.send_request('setvol "-10"')
|
||||
self.assertEqual(0, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_min(self):
|
||||
self.send_request('setvol "0"')
|
||||
self.assertEqual(0, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_middle(self):
|
||||
self.send_request('setvol "50"')
|
||||
self.assertEqual(50, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_max(self):
|
||||
self.send_request('setvol "100"')
|
||||
self.assertEqual(100, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_above_max(self):
|
||||
self.send_request('setvol "110"')
|
||||
self.assertEqual(100, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_plus_is_ignored(self):
|
||||
self.send_request('setvol "+10"')
|
||||
self.assertEqual(10, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_without_quotes(self):
|
||||
self.send_request('setvol 50')
|
||||
self.assertEqual(50, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_single_off(self):
|
||||
self.send_request('single "0"')
|
||||
self.assertFalse(self.core.tracklist.single.get())
|
||||
@ -451,9 +416,83 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase):
|
||||
self.assertInResponse('OK')
|
||||
|
||||
|
||||
class PlaybackOptionsHandlerNoneMixerTest(protocol.BaseTestCase):
|
||||
class VolumeTest(protocol.BaseTestCase):
|
||||
|
||||
def test_setvol_below_min(self):
|
||||
self.send_request('setvol "-10"')
|
||||
self.assertEqual(0, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_min(self):
|
||||
self.send_request('setvol "0"')
|
||||
self.assertEqual(0, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_middle(self):
|
||||
self.send_request('setvol "50"')
|
||||
self.assertEqual(50, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_max(self):
|
||||
self.send_request('setvol "100"')
|
||||
self.assertEqual(100, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_above_max(self):
|
||||
self.send_request('setvol "110"')
|
||||
self.assertEqual(100, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_plus_is_ignored(self):
|
||||
self.send_request('setvol "+10"')
|
||||
self.assertEqual(10, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_setvol_without_quotes(self):
|
||||
self.send_request('setvol 50')
|
||||
self.assertEqual(50, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_volume_plus(self):
|
||||
self.core.mixer.set_volume(50)
|
||||
|
||||
self.send_request('volume +20')
|
||||
|
||||
self.assertEqual(70, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_volume_minus(self):
|
||||
self.core.mixer.set_volume(50)
|
||||
|
||||
self.send_request('volume -20')
|
||||
|
||||
self.assertEqual(30, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_volume_less_than_minus_100(self):
|
||||
self.core.mixer.set_volume(50)
|
||||
|
||||
self.send_request('volume -110')
|
||||
|
||||
self.assertEqual(50, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('ACK [2@0] {volume} Invalid volume value')
|
||||
|
||||
def test_volume_more_than_plus_100(self):
|
||||
self.core.mixer.set_volume(50)
|
||||
|
||||
self.send_request('volume +110')
|
||||
|
||||
self.assertEqual(50, self.core.mixer.get_volume().get())
|
||||
self.assertInResponse('ACK [2@0] {volume} Invalid volume value')
|
||||
|
||||
|
||||
class VolumeWithNoMixerTest(protocol.BaseTestCase):
|
||||
enable_mixer = False
|
||||
|
||||
def test_setvol_max_error(self):
|
||||
def test_setvol_without_mixer_fails(self):
|
||||
self.send_request('setvol "100"')
|
||||
self.assertInResponse('ACK [52@0] {setvol} problems setting volume')
|
||||
|
||||
def test_volume_without_mixer_failes(self):
|
||||
self.send_request('volume +100')
|
||||
self.assertInResponse('ACK [52@0] {volume} problems setting volume')
|
||||
|
||||
@ -56,7 +56,7 @@ class TrackMpdFormatTest(unittest.TestCase):
|
||||
|
||||
def test_track_to_mpd_format_with_position_and_tlid(self):
|
||||
result = translator.track_to_mpd_format(
|
||||
TlTrack(2, Track()), position=1)
|
||||
TlTrack(2, Track(uri='a uri')), position=1)
|
||||
self.assertIn(('Pos', 1), result)
|
||||
self.assertIn(('Id', 2), result)
|
||||
|
||||
@ -153,13 +153,17 @@ class PlaylistMpdFormatTest(unittest.TestCase):
|
||||
|
||||
def test_mpd_format(self):
|
||||
playlist = Playlist(tracks=[
|
||||
Track(track_no=1), Track(track_no=2), Track(track_no=3)])
|
||||
Track(uri='foo', track_no=1),
|
||||
Track(uri='bar', track_no=2),
|
||||
Track(uri='baz', track_no=3)])
|
||||
result = translator.playlist_to_mpd_format(playlist)
|
||||
self.assertEqual(len(result), 3)
|
||||
|
||||
def test_mpd_format_with_range(self):
|
||||
playlist = Playlist(tracks=[
|
||||
Track(track_no=1), Track(track_no=2), Track(track_no=3)])
|
||||
Track(uri='foo', track_no=1),
|
||||
Track(uri='bar', track_no=2),
|
||||
Track(uri='baz', track_no=3)])
|
||||
result = translator.playlist_to_mpd_format(playlist, 1, 2)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(dict(result[0])['Track'], 2)
|
||||
|
||||
@ -38,7 +38,9 @@ def audio():
|
||||
|
||||
@pytest.fixture
|
||||
def scanner():
|
||||
return mock.Mock(spec=scan.Scanner)
|
||||
scan_mock = mock.Mock(spec=scan.Scanner)
|
||||
scan_mock.scan.return_value = None
|
||||
return scan_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -58,7 +60,24 @@ class TestTranslateURI(object):
|
||||
|
||||
@responses.activate
|
||||
def test_audio_stream_returns_same_uri(self, scanner, provider):
|
||||
scanner.scan.return_value.mime = 'audio/mpeg'
|
||||
scanner.scan.side_effect = [
|
||||
# Set playable to False to test detection by mimetype
|
||||
mock.Mock(mime='audio/mpeg', playable=False),
|
||||
]
|
||||
|
||||
result = provider.translate_uri(STREAM_URI)
|
||||
|
||||
scanner.scan.assert_called_once_with(STREAM_URI, timeout=mock.ANY)
|
||||
assert result == STREAM_URI
|
||||
|
||||
@responses.activate
|
||||
def test_playable_ogg_stream_is_not_considered_a_playlist(
|
||||
self, scanner, provider):
|
||||
|
||||
scanner.scan.side_effect = [
|
||||
# Set playable to True to ignore detection as possible playlist
|
||||
mock.Mock(mime='application/ogg', playable=True),
|
||||
]
|
||||
|
||||
result = provider.translate_uri(STREAM_URI)
|
||||
|
||||
@ -70,8 +89,10 @@ class TestTranslateURI(object):
|
||||
self, scanner, provider, caplog):
|
||||
|
||||
scanner.scan.side_effect = [
|
||||
mock.Mock(mime='text/foo'), # scanning playlist
|
||||
mock.Mock(mime='audio/mpeg'), # scanning stream
|
||||
# Scanning playlist
|
||||
mock.Mock(mime='text/foo', playable=False),
|
||||
# Scanning stream
|
||||
mock.Mock(mime='audio/mpeg', playable=True),
|
||||
]
|
||||
responses.add(
|
||||
responses.GET, PLAYLIST_URI,
|
||||
@ -100,8 +121,10 @@ class TestTranslateURI(object):
|
||||
@responses.activate
|
||||
def test_xml_playlist_with_mpeg_stream(self, scanner, provider):
|
||||
scanner.scan.side_effect = [
|
||||
mock.Mock(mime='application/xspf+xml'), # scanning playlist
|
||||
mock.Mock(mime='audio/mpeg'), # scanning stream
|
||||
# Scanning playlist
|
||||
mock.Mock(mime='application/xspf+xml', playable=False),
|
||||
# Scanning stream
|
||||
mock.Mock(mime='audio/mpeg', playable=True),
|
||||
]
|
||||
responses.add(
|
||||
responses.GET, PLAYLIST_URI,
|
||||
@ -120,8 +143,10 @@ class TestTranslateURI(object):
|
||||
self, scanner, provider, caplog):
|
||||
|
||||
scanner.scan.side_effect = [
|
||||
exceptions.ScannerError('some failure'), # scanning playlist
|
||||
mock.Mock(mime='audio/mpeg'), # scanning stream
|
||||
# Scanning playlist
|
||||
exceptions.ScannerError('some failure'),
|
||||
# Scanning stream
|
||||
mock.Mock(mime='audio/mpeg', playable=True),
|
||||
]
|
||||
responses.add(
|
||||
responses.GET, PLAYLIST_URI,
|
||||
@ -169,7 +194,9 @@ class TestTranslateURI(object):
|
||||
|
||||
@responses.activate
|
||||
def test_playlist_references_itself(self, scanner, provider, caplog):
|
||||
scanner.scan.return_value.mime = 'text/foo'
|
||||
scanner.scan.side_effect = [
|
||||
mock.Mock(mime='text/foo', playable=False)
|
||||
]
|
||||
responses.add(
|
||||
responses.GET, PLAYLIST_URI,
|
||||
body=BODY.replace(STREAM_URI, PLAYLIST_URI),
|
||||
|
||||
@ -9,6 +9,7 @@ from mopidy import httpclient
|
||||
|
||||
@pytest.mark.parametrize("config,expected", [
|
||||
({}, None),
|
||||
({'hostname': ''}, None),
|
||||
({'hostname': 'proxy.lan'}, 'http://proxy.lan:80'),
|
||||
({'scheme': None, 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'),
|
||||
({'scheme': 'https', 'hostname': 'proxy.lan'}, 'https://proxy.lan:80'),
|
||||
@ -16,6 +17,8 @@ from mopidy import httpclient
|
||||
({'password': 'pass', 'hostname': 'proxy.lan'}, 'http://proxy.lan:80'),
|
||||
({'hostname': 'proxy.lan', 'port': 8080}, 'http://proxy.lan:8080'),
|
||||
({'hostname': 'proxy.lan', 'port': -1}, 'http://proxy.lan:80'),
|
||||
({'hostname': 'proxy.lan', 'port': None}, 'http://proxy.lan:80'),
|
||||
({'hostname': 'proxy.lan', 'port': ''}, 'http://proxy.lan:80'),
|
||||
({'username': 'user', 'password': 'pass', 'hostname': 'proxy.lan'},
|
||||
'http://user:pass@proxy.lan:80'),
|
||||
])
|
||||
|
||||
@ -8,62 +8,5 @@ from mopidy import __version__
|
||||
|
||||
class VersionTest(unittest.TestCase):
|
||||
|
||||
def assertVersionLess(self, first, second): # noqa: N802
|
||||
self.assertLess(StrictVersion(first), StrictVersion(second))
|
||||
|
||||
def test_current_version_is_parsable_as_a_strict_version_number(self):
|
||||
StrictVersion(__version__)
|
||||
|
||||
def test_versions_can_be_strictly_ordered(self):
|
||||
self.assertVersionLess('0.1.0a0', '0.1.0a1')
|
||||
self.assertVersionLess('0.1.0a1', '0.1.0a2')
|
||||
self.assertVersionLess('0.1.0a2', '0.1.0a3')
|
||||
self.assertVersionLess('0.1.0a3', '0.1.0')
|
||||
self.assertVersionLess('0.1.0', '0.2.0')
|
||||
self.assertVersionLess('0.1.0', '1.0.0')
|
||||
self.assertVersionLess('0.2.0', '0.3.0')
|
||||
self.assertVersionLess('0.3.0', '0.3.1')
|
||||
self.assertVersionLess('0.3.1', '0.4.0')
|
||||
self.assertVersionLess('0.4.0', '0.4.1')
|
||||
self.assertVersionLess('0.4.1', '0.5.0')
|
||||
self.assertVersionLess('0.5.0', '0.6.0')
|
||||
self.assertVersionLess('0.6.0', '0.6.1')
|
||||
self.assertVersionLess('0.6.1', '0.7.0')
|
||||
self.assertVersionLess('0.7.0', '0.7.1')
|
||||
self.assertVersionLess('0.7.1', '0.7.2')
|
||||
self.assertVersionLess('0.7.2', '0.7.3')
|
||||
self.assertVersionLess('0.7.3', '0.8.0')
|
||||
self.assertVersionLess('0.8.0', '0.8.1')
|
||||
self.assertVersionLess('0.8.1', '0.9.0')
|
||||
self.assertVersionLess('0.9.0', '0.10.0')
|
||||
self.assertVersionLess('0.10.0', '0.11.0')
|
||||
self.assertVersionLess('0.11.0', '0.11.1')
|
||||
self.assertVersionLess('0.11.1', '0.12.0')
|
||||
self.assertVersionLess('0.12.0', '0.13.0')
|
||||
self.assertVersionLess('0.13.0', '0.14.0')
|
||||
self.assertVersionLess('0.14.0', '0.14.1')
|
||||
self.assertVersionLess('0.14.1', '0.14.2')
|
||||
self.assertVersionLess('0.14.2', '0.15.0')
|
||||
self.assertVersionLess('0.15.0', '0.16.0')
|
||||
self.assertVersionLess('0.16.0', '0.17.0')
|
||||
self.assertVersionLess('0.17.0', '0.18.0')
|
||||
self.assertVersionLess('0.18.0', '0.18.1')
|
||||
self.assertVersionLess('0.18.1', '0.18.2')
|
||||
self.assertVersionLess('0.18.2', '0.18.3')
|
||||
self.assertVersionLess('0.18.3', '0.19.0')
|
||||
self.assertVersionLess('0.19.0', '0.19.1')
|
||||
self.assertVersionLess('0.19.1', '0.19.2')
|
||||
self.assertVersionLess('0.19.2', '0.19.3')
|
||||
self.assertVersionLess('0.19.3', '0.19.4')
|
||||
self.assertVersionLess('0.19.4', '0.19.5')
|
||||
self.assertVersionLess('0.19.5', '1.0.0')
|
||||
self.assertVersionLess('1.0.0', '1.0.1')
|
||||
self.assertVersionLess('1.0.1', '1.0.2')
|
||||
self.assertVersionLess('1.0.2', '1.0.3')
|
||||
self.assertVersionLess('1.0.3', '1.0.4')
|
||||
self.assertVersionLess('1.0.4', '1.0.5')
|
||||
self.assertVersionLess('1.0.5', '1.0.6')
|
||||
self.assertVersionLess('1.0.6', '1.0.7')
|
||||
self.assertVersionLess('1.0.7', '1.0.8')
|
||||
self.assertVersionLess('1.0.8', __version__)
|
||||
self.assertVersionLess(__version__, '1.1.1')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user