diff --git a/.gitignore b/.gitignore index 990d75ca..d5d8194c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/api/models.rst b/docs/api/models.rst index 27c7647f..cc8518ba 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -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 diff --git a/docs/authors.rst b/docs/authors.rst index 90ec6f23..f4f93d56 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -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 `_. diff --git a/docs/changelog.rst b/docs/changelog.rst index 6932fb54..8054ac82 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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) =================== diff --git a/docs/conf.py b/docs/conf.py index cbb2f228..3a93cc90 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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'] diff --git a/docs/debian.rst b/docs/debian.rst deleted file mode 100644 index f761c4b0..00000000 --- a/docs/debian.rst +++ /dev/null @@ -1,129 +0,0 @@ -.. _debian: - -*************** -Debian packages -*************** - -The Mopidy Debian package, ``mopidy``, is available from `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 ``. 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 `` 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. diff --git a/docs/index.rst b/docs/index.rst index 70d14a73..e6b2da98 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -81,8 +81,8 @@ announcements related to Mopidy and Mopidy extensions. installation/index config running + service troubleshooting - debian .. _ext: diff --git a/docs/installation/arch.rst b/docs/installation/arch.rst index c5675403..59928a3a 100644 --- a/docs/installation/arch.rst +++ b/docs/installation/arch.rst @@ -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 `, and - then you're ready to :doc:`run Mopidy `. + then you're ready to :doc:`run Mopidy ` or run Mopidy as a + :ref:`service `. Installing extensions diff --git a/docs/installation/debian.rst b/docs/installation/debian.rst index a04dfa25..a1ad339e 100644 --- a/docs/installation/debian.rst +++ b/docs/installation/debian.rst @@ -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 `, and then - you're ready to :doc:`run Mopidy `. + you're ready to :doc:`run Mopidy ` or run Mopidy as a + :ref:`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`. diff --git a/docs/installation/osx.rst b/docs/installation/osx.rst index e9ce16e3..45c554ea 100644 --- a/docs/installation/osx.rst +++ b/docs/installation/osx.rst @@ -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 ===================================== diff --git a/docs/installation/raspberry-pi-by-jwrodgers.jpg b/docs/installation/raspberry-pi-by-jwrodgers.jpg deleted file mode 100644 index d093bb88..00000000 Binary files a/docs/installation/raspberry-pi-by-jwrodgers.jpg and /dev/null differ diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index c8793496..c2fd10ad 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -1,75 +1,68 @@ .. _raspberrypi-installation: -************************************* -Raspberry Pi: Mopidy on a credit card -************************************* +************ +Raspberry Pi +************ -Mopidy runs nicely on a `Raspberry Pi `_. 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 `_. +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 + `_ + 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 `_ - 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 -`_. - -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 -`_. - -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`` diff --git a/docs/installation/raspberrypi2.jpg b/docs/installation/raspberrypi2.jpg new file mode 100644 index 00000000..8af91864 Binary files /dev/null and b/docs/installation/raspberrypi2.jpg differ diff --git a/docs/running.rst b/docs/running.rst index e329ccaa..1aa0a657 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -39,21 +39,8 @@ using ``pkill``:: pkill mopidy -Init scripts -============ +Running as a service +==================== -- The ``mopidy`` package at `apt.mopidy.com `__ comes - with an `sysvinit init script - `_. For - more details, see the :ref:`debian` section of the docs. - -- The ``mopidy`` package in `Arch Linux - `__ 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 - `_. - -- 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`. diff --git a/docs/service.rst b/docs/service.rst new file mode 100644 index 00000000..e99e1645 --- /dev/null +++ b/docs/service.rst @@ -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 ``. 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`. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index df9aacc3..59d0444e 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -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' diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index ca2c308c..fd5d2d49 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -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: diff --git a/mopidy/core/library.py b/mopidy/core/library.py index ce420812..240de619 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -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] diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index d95bd491..5b7ea221 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -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): """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 9a11066b..dd484ff8 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -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): diff --git a/mopidy/ext.py b/mopidy/ext.py index 7fd68f96..fe8d0daf 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -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() diff --git a/mopidy/httpclient.py b/mopidy/httpclient.py index 682a78bd..6be127ca 100644 --- a/mopidy/httpclient.py +++ b/mopidy/httpclient.py @@ -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: diff --git a/mopidy/internal/http.py b/mopidy/internal/http.py index e35b8561..751d04ce 100644 --- a/mopidy/internal/http.py +++ b/mopidy/internal/http.py @@ -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 = [] diff --git a/mopidy/internal/log.py b/mopidy/internal/log.py index 9c40da4f..011a70d2 100644 --- a/mopidy/internal/log.py +++ b/mopidy/internal/log.py @@ -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: diff --git a/mopidy/listener.py b/mopidy/listener.py index 35bd8b73..7b129955 100644 --- a/mopidy/listener.py +++ b/mopidy/listener.py @@ -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) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 7033f3aa..d61cf441 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -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']) diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index 7afa2db8..1ea23be0 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -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) diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index 8eb59c1f..58c758e4 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -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') diff --git a/mopidy/mpd/dispatcher.py b/mopidy/mpd/dispatcher.py index 099a2f18..175d8b32 100644 --- a/mopidy/mpd/dispatcher.py +++ b/mopidy/mpd/dispatcher.py @@ -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( diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 333e1ccb..48aaae2c 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -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') diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 4aa4bdb9..a76d6d59 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -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 diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index b3bf0b30..818d570e 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -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: diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 92b22bfb..750f371f 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -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') diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 5a8c9649..46564860 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -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) diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index b9adb646..de02ae36 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -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') diff --git a/tests/mpd/test_translator.py b/tests/mpd/test_translator.py index 65c80bbb..e1ef703d 100644 --- a/tests/mpd/test_translator.py +++ b/tests/mpd/test_translator.py @@ -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) diff --git a/tests/stream/test_playback.py b/tests/stream/test_playback.py index 4c42b1cd..ef7da0bf 100644 --- a/tests/stream/test_playback.py +++ b/tests/stream/test_playback.py @@ -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), diff --git a/tests/test_httpclient.py b/tests/test_httpclient.py index 63591f80..30f03d8d 100644 --- a/tests/test_httpclient.py +++ b/tests/test_httpclient.py @@ -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'), ]) diff --git a/tests/test_version.py b/tests/test_version.py index 011c8de7..41607c98 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -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')