diff --git a/.travis.yml b/.travis.yml index 964ae89f..f46d5ae2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,7 +16,7 @@ env: before_install: - "sudo sed -i '/127.0.1.1/d' /etc/hosts" # Workaround tornadoweb/tornado#1573 - "sudo apt-get update -qq" - - "sudo apt-get install -y graphviz-dev gstreamer0.10-plugins-good python-gst0.10" + - "sudo apt-get install -y gir1.2-gst-plugins-base-1.0 gir1.2-gstreamer-1.0 graphviz-dev gstreamer1.0-plugins-good gstreamer1.0-plugins-bad python-gst-1.0" install: - "pip install tox" diff --git a/AUTHORS b/AUTHORS index 38c394dc..4cf69baa 100644 --- a/AUTHORS +++ b/AUTHORS @@ -76,3 +76,4 @@ - Jelle van der Waa - Alex Malone - Daniel Hahler +- Bryan Bennett 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 85bb28e2..d9be847e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,12 @@ v1.2.0 (UNRELEASED) Feature release. +Dependencies +------------ + +- Mopidy now requires GStreamer 1.x, as we've finally ported from GStreamer + 0.10. + Core API -------- @@ -126,6 +132,33 @@ Cleanups - Catch errors when loading :confval:`logging/config_file`. (Fixes: :issue:`1320`) +Audio +----- + +- **Breaking:** The audio scanner now returns ISO-8601 formatted strings + instead of :class:`~datetime.datetime` objects for dates found in tags. + Because of this change, we can now return years without months or days, which + matches the semantics of the date fields in our data models. + +- **Breaking:** :meth:`mopidy.audio.Audio.set_appsrc`'s ``caps`` argument has + changed format due to the upgrade from GStreamer 0.10 to GStreamer 1. As + far as we know, this is only used by Mopidy-Spotify. As an example, with + GStreamer 0.10 the Mopidy-Spotify caps was:: + + audio/x-raw-int, endianness=(int)1234, channels=(int)2, width=(int)16, + depth=(int)16, signed=(boolean)true, rate=(int)44100 + + With GStreamer 1 this changes to:: + + audio/x-raw,format=S16LE,rate=44100,channels=2,layout=interleaved + + If your Mopidy backend uses ``set_appsrc()``, please refer to GStreamer + documentation for details on the new caps string format. + +- **Deprecated:** :func:`mopidy.audio.utils.create_buffer`'s ``capabilities`` + argument is no longer in use and will be removed in the future. As far as we + know, this is only used by Mopidy-Spotify. + Gapless ------- @@ -144,11 +177,28 @@ Gapless cases. (Fixes: :issue:`1305` PR: :issue:`1346`) -v1.1.2 (UNRELEASED) +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: @@ -157,6 +207,18 @@ Bug fix release. - 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/clients/mpd.rst b/docs/clients/mpd.rst index b070092a..ee1b1903 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -167,5 +167,5 @@ projects are a real match made in heaven." Partify ------- -`Partify `_ is a web based MPD client focussing on -making music playing collaborative and social. +`Partify `_ is a web based MPD client +focussing on making music playing collaborative and social. diff --git a/docs/conf.py b/docs/conf.py index bd553ae4..5dcebc31 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/config.rst b/docs/config.rst index 3a2046f7..fa8018f8 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -25,6 +25,10 @@ create the configuration file yourself, or run the ``mopidy`` command, and it will create an empty config file for you and print what config values must be set to successfully start Mopidy. +If running Mopidy as a service, the location of the config file and other +details documented here differs a bit. See :ref:`service` for details about +this. + When you have created the configuration file, open it in a text editor, and add the config values you want to change. If you want to keep the default for a config value, you **should not** add it to the config file, but leave it out so 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/ext/backends.rst b/docs/ext/backends.rst index 7a9dc506..2349006b 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -115,7 +115,7 @@ Bundled with Mopidy. See :ref:`ext-local`. Mopidy-Local-Images =================== -https://github.com/tkem/mopidy-local-images +https://github.com/mopidy/mopidy-local-images Extension which plugs into Mopidy-Local to allow Web clients access to album art embedded in local media files. Not to be used on its own, @@ -126,7 +126,7 @@ local library provider being used. Mopidy-Local-SQLite =================== -https://github.com/tkem/mopidy-local-sqlite +https://github.com/mopidy/mopidy-local-sqlite Extension which plugs into Mopidy-Local to use an SQLite database to keep track of your local media. This extension lets you browse your music collection diff --git a/docs/ext/spotmop.jpg b/docs/ext/spotmop.jpg new file mode 100644 index 00000000..88393f0b Binary files /dev/null and b/docs/ext/spotmop.jpg differ diff --git a/docs/ext/web.rst b/docs/ext/web.rst index a6e2d748..4c2b6c6c 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -164,6 +164,22 @@ To install, run:: pip install Mopidy-Simple-Webclient +Mopidy-Spotmop +============== + +https://github.com/jaedb/spotmop + +A client targeted at Spotify users. Made by James Barnsley. + +.. image:: /ext/spotmop.jpg + :width: 720 + :height: 455 + +To install, run:: + + pip install Mopidy-Spotmop + + Mopidy-WebSettings ================== 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 9bf9f550..6d2dd3cd 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 - https://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 issue -`_. - -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/installation/source.rst b/docs/installation/source.rst index 204cc1df..e57ddc18 100644 --- a/docs/installation/source.rst +++ b/docs/installation/source.rst @@ -37,36 +37,40 @@ please follow the directions :ref:`here `. On Fedora Linux, you must replace ``pip`` with ``pip-python`` in the following steps. -#. Then you'll need to install GStreamer 0.10 (>= 0.10.31, < 0.11), with Python - bindings. GStreamer is packaged for most popular Linux distributions. Search - for GStreamer in your package manager, and make sure to install the Python +#. Then you'll need to install GStreamer 1.x (>= 1.2), with Python bindings. + GStreamer is packaged for most popular Linux distributions. Search for + GStreamer in your package manager, and make sure to install the Python bindings, and the "good" and "ugly" plugin sets. If you use Debian/Ubuntu you can install GStreamer like this:: - sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly gstreamer0.10-tools + sudo apt-get install python-gst-1.0 gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-ugly gstreamer1.0-tools If you use Arch Linux, install the following packages from the official repository:: - sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ - gstreamer0.10-ugly-plugins + sudo pacman -S python2-gobject gst-python gst-plugins-good + gst-plugins-ugly + + .. warning:: + + ``gst-python`` installs GStreamer GI overrides for Python 3. As far as + we know, Arch currently lacks a package with the corresponding overrides + built for Python 2. If a ``gst-python2`` package is added, it will + depend on ``python2-gobject``, so we can then shorten this package list. If you use Fedora you can install GStreamer like this:: - sudo yum install -y python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly gstreamer0.10-tools + sudo yum install -y python-gstreamer1 gstreamer1-plugins-good \ + gstreamer1-plugins-ugly - If you use Gentoo you need to be careful because GStreamer 0.10 is in a - different lower slot than 1.0, the default. Your emerge commands will need - to include the slot:: + If you use Gentoo you can install GStreamer like this:: - emerge -av gst-python gst-plugins-bad:0.10 gst-plugins-good:0.10 \ - gst-plugins-ugly:0.10 gst-plugins-meta:0.10 + emerge -av gst-python gst-plugins-meta - ``gst-plugins-meta:0.10`` is the one that actually pulls in the plugins you - want, so pay attention to the use flags, e.g. ``alsa``, ``mp3``, etc. + ``gst-plugins-meta`` is the one that actually pulls in the plugins you want, + so pay attention to the USE flags, e.g. ``alsa``, ``mp3``, etc. #. Install the latest release of Mopidy:: @@ -76,11 +80,6 @@ please follow the directions :ref:`here `. `_. To upgrade Mopidy to future releases, just rerun this command. - Alternatively, if you want to track Mopidy development closer, you may - install a snapshot of Mopidy's ``develop`` Git branch using pip:: - - sudo pip install --allow-unverified=mopidy mopidy==dev - #. Finally, you need to set a couple of :doc:`config values `, and then you're ready to :doc:`run Mopidy `. diff --git a/docs/running.rst b/docs/running.rst index 73bd211f..1aa0a657 100644 --- a/docs/running.rst +++ b/docs/running.rst @@ -39,17 +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. - -- 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..10c47a68 --- /dev/null +++ b/docs/service.rst @@ -0,0 +1,137 @@ +.. _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/mopidy.conf`, not in your user's +home directory. + + +mopidy user +=========== + +The Mopidy service runs 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`. + + +Configure PulseAudio +==================== + +When using PulseAudio, you will typically have a PulseAudio server run by your +main user. Since Mopidy is running as its own user, it can't access this server +directly. Running PulseAudio as a system-wide daemon is discouraged by upstream +(see `here +`_ +for details). Rather you can configure PulseAudio and Mopidy so Mopidy sends +the sound to the PulseAudio server already running as your main user. + +First, configure PulseAudio to accept sound over TCP from localhost by +uncommenting or adding the TCP module to :file:`/etc/pulse/default.pa` or +:file:`$XDG_CONFIG_HOME/pulse/default.pa` (typically +:file:`~/.config/pulse/default.pa`):: + + ### Network access (may be configured with paprefs, so leave this commented + ### here if you plan to use paprefs) + #load-module module-esound-protocol-tcp + load-module module-native-protocol-tcp auth-ip-acl=127.0.0.1 + #load-module module-zeroconf-publish + +Next, configure Mopidy to use this PulseAudio server:: + + [audio] + output = pulsesink server=127.0.0.1 + +After this, restart both PulseAudio and Mopidy:: + + pulseaudio --kill + start-pulseaudio-x11 + sudo systemctl restart mopidy + +If you are not running any X server, run ``pulseaudio --start`` instead of +``start-pulseaudio-x11``. + +If you don't want to hard code the output in your Mopidy config, you can +instead of adding any config to Mopidy add this to +:file:`~mopidy/.pulse/client.conf`:: + + default-server=127.0.0.1 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/__main__.py b/mopidy/__main__.py index fbc750af..ee87b82d 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -4,24 +4,8 @@ import logging import os import signal import sys -import textwrap -try: - import gobject # noqa -except ImportError: - print(textwrap.dedent(""" - ERROR: The gobject Python package was not found. - - Mopidy requires GStreamer (and GObject) to work. These are C libraries - with a number of dependencies themselves, and cannot be installed with - the regular Python tools like pip. - - Please see http://docs.mopidy.com/en/latest/installation/ for - instructions on how to install the required dependencies. - """)) - raise - -gobject.threads_init() +from mopidy.internal.gi import Gst # noqa: Import to initialize try: # Make GObject's mainloop the event loop for python-dbus @@ -33,13 +17,6 @@ except ImportError: import pykka.debug - -# Extract any command line arguments. This needs to be done before GStreamer is -# imported, so that GStreamer doesn't hijack e.g. ``--help``. -mopidy_args = sys.argv[1:] -sys.argv[1:] = [] - - from mopidy import commands, config as config_lib, ext from mopidy.internal import encoding, log, path, process, versioning @@ -73,7 +50,7 @@ def main(): data.command.set(extension=data.extension) root_cmd.add_child(data.extension.ext_name, data.command) - args = root_cmd.parse(mopidy_args) + args = root_cmd.parse(sys.argv[1:]) config, config_errors = config_lib.load( args.config_files, diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index b8b3d9a4..db923e6d 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -4,65 +4,28 @@ import logging import os import threading -import gobject - -import pygst -pygst.require('0.10') -import gst # noqa -import gst.pbutils # noqa - import pykka from mopidy import exceptions -from mopidy.audio import icy, utils +from mopidy.audio import tags as tags_lib, utils from mopidy.audio.constants import PlaybackState from mopidy.audio.listener import AudioListener from mopidy.internal import deprecation, process +from mopidy.internal.gi import GObject, Gst, GstPbutils logger = logging.getLogger(__name__) -# This logger is only meant for debug logging of low level gstreamer info such +# This logger is only meant for debug logging of low level GStreamer info such # as callbacks, event, messages and direct interaction with GStreamer such as -# set_state on a pipeline. +# set_state() on a pipeline. gst_logger = logging.getLogger('mopidy.audio.gst') -icy.register() - _GST_STATE_MAPPING = { - gst.STATE_PLAYING: PlaybackState.PLAYING, - gst.STATE_PAUSED: PlaybackState.PAUSED, - gst.STATE_NULL: PlaybackState.STOPPED} - - -class _Signals(object): - - """Helper for tracking gobject signal registrations""" - - def __init__(self): - self._ids = {} - - def connect(self, element, event, func, *args): - """Connect a function + args to signal event on an element. - - Each event may only be handled by one callback in this implementation. - """ - assert (element, event) not in self._ids - self._ids[(element, event)] = element.connect(event, func, *args) - - def disconnect(self, element, event): - """Disconnect whatever handler we have for and element+event pair. - - Does nothing it the handler has already been removed. - """ - signal_id = self._ids.pop((element, event), None) - if signal_id is not None: - element.disconnect(signal_id) - - def clear(self): - """Clear all registered signal handlers.""" - for element, event in self._ids.keys(): - element.disconnect(self._ids.pop((element, event))) + Gst.State.PLAYING: PlaybackState.PLAYING, + Gst.State.PAUSED: PlaybackState.PAUSED, + Gst.State.NULL: PlaybackState.STOPPED, +} # TODO: expose this as a property on audio? @@ -71,7 +34,7 @@ class _Appsrc(object): """Helper class for dealing with appsrc based playback.""" def __init__(self): - self._signals = _Signals() + self._signals = utils.Signals() self.reset() def reset(self): @@ -120,9 +83,11 @@ class _Appsrc(object): if buffer_ is None: gst_logger.debug('Sending appsrc end-of-stream event.') - return self._source.emit('end-of-stream') == gst.FLOW_OK + result = self._source.emit('end-of-stream') + return result == Gst.FlowReturn.OK else: - return self._source.emit('push-buffer', buffer_) == gst.FLOW_OK + result = self._source.emit('push-buffer', buffer_) + return result == Gst.FlowReturn.OK def _on_signal(self, element, clocktime, func): # This shim is used to ensure we always return true, and also handles @@ -135,29 +100,30 @@ class _Appsrc(object): # TODO: expose this as a property on audio when #790 gets further along. -class _Outputs(gst.Bin): +class _Outputs(Gst.Bin): def __init__(self): - gst.Bin.__init__(self, 'outputs') + Gst.Bin.__init__(self) + # TODO gst1: Set 'outputs' as the Bin name for easier debugging - self._tee = gst.element_factory_make('tee') + self._tee = Gst.ElementFactory.make('tee') self.add(self._tee) - ghost_pad = gst.GhostPad('sink', self._tee.get_pad('sink')) + ghost_pad = Gst.GhostPad.new('sink', self._tee.get_static_pad('sink')) self.add_pad(ghost_pad) # Add an always connected fakesink which respects the clock so the tee # doesn't fail even if we don't have any outputs. - fakesink = gst.element_factory_make('fakesink') + fakesink = Gst.ElementFactory.make('fakesink') fakesink.set_property('sync', True) self._add(fakesink) def add_output(self, description): # XXX This only works for pipelines not in use until #790 gets done. try: - output = gst.parse_bin_from_description( - description, ghost_unconnected_pads=True) - except gobject.GError as ex: + output = Gst.parse_bin_from_description( + description, ghost_unlinked_pads=True) + except GObject.GError as ex: logger.error( 'Failed to create audio output "%s": %s', description, ex) raise exceptions.AudioException(bytes(ex)) @@ -166,7 +132,7 @@ class _Outputs(gst.Bin): logger.info('Audio output set to "%s"', description) def _add(self, element): - queue = gst.element_factory_make('queue') + queue = Gst.ElementFactory.make('queue') self.add(element) self.add(queue) queue.link(element) @@ -181,7 +147,7 @@ class SoftwareMixer(object): self._element = None self._last_volume = None self._last_mute = None - self._signals = _Signals() + self._signals = utils.Signals() def setup(self, element, mixer_ref): self._element = element @@ -223,7 +189,8 @@ class _Handler(object): def setup_event_handling(self, pad): self._pad = pad - self._event_handler_id = pad.add_event_probe(self.on_event) + self._event_handler_id = pad.add_probe( + Gst.PadProbeType.EVENT_BOTH, self.on_pad_event) def teardown_message_handling(self): bus = self._element.get_bus() @@ -232,55 +199,59 @@ class _Handler(object): self._message_handler_id = None def teardown_event_handling(self): - self._pad.remove_event_probe(self._event_handler_id) + self._pad.remove_probe(self._event_handler_id) self._event_handler_id = None def on_message(self, bus, msg): - if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element: - self.on_playbin_state_changed(*msg.parse_state_changed()) - elif msg.type == gst.MESSAGE_BUFFERING: - self.on_buffering(msg.parse_buffering(), msg.structure) - elif msg.type == gst.MESSAGE_EOS: + if msg.type == Gst.MessageType.STATE_CHANGED: + if msg.src != self._element: + return + old_state, new_state, pending_state = msg.parse_state_changed() + self.on_playbin_state_changed(old_state, new_state, pending_state) + elif msg.type == Gst.MessageType.BUFFERING: + self.on_buffering(msg.parse_buffering(), msg.get_structure()) + elif msg.type == Gst.MessageType.EOS: self.on_end_of_stream() - elif msg.type == gst.MESSAGE_ERROR: - self.on_error(*msg.parse_error()) - elif msg.type == gst.MESSAGE_WARNING: - self.on_warning(*msg.parse_warning()) - elif msg.type == gst.MESSAGE_ASYNC_DONE: + elif msg.type == Gst.MessageType.ERROR: + error, debug = msg.parse_error() + self.on_error(error, debug) + elif msg.type == Gst.MessageType.WARNING: + error, debug = msg.parse_warning() + self.on_warning(error, debug) + elif msg.type == Gst.MessageType.ASYNC_DONE: self.on_async_done() - elif msg.type == gst.MESSAGE_TAG: - self.on_tag(msg.parse_tag()) - elif msg.type == gst.MESSAGE_ELEMENT: - if gst.pbutils.is_missing_plugin_message(msg): + elif msg.type == Gst.MessageType.TAG: + taglist = msg.parse_tag() + self.on_tag(taglist) + elif msg.type == Gst.MessageType.ELEMENT: + if GstPbutils.is_missing_plugin_message(msg): self.on_missing_plugin(msg) + elif msg.type == Gst.MessageType.STREAM_START: + self.on_stream_start() - def on_event(self, pad, event): - if event.type == gst.EVENT_NEWSEGMENT: - self.on_new_segment(*event.parse_new_segment()) - elif event.type == gst.EVENT_SINK_MESSAGE: - # Handle stream changed messages when they reach our output bin. - # If we listen for it on the bus we get one per tee branch. - msg = event.parse_sink_message() - if msg.structure.has_name('playbin2-stream-changed'): - self.on_stream_changed(msg.structure['uri']) - return True + def on_pad_event(self, pad, pad_probe_info): + event = pad_probe_info.get_event() + if event.type == Gst.EventType.SEGMENT: + self.on_segment(event.parse_segment()) + return Gst.PadProbeReturn.OK def on_playbin_state_changed(self, old_state, new_state, pending_state): - gst_logger.debug('Got state-changed message: old=%s new=%s pending=%s', - old_state.value_name, new_state.value_name, - pending_state.value_name) + gst_logger.debug( + 'Got STATE_CHANGED bus message: old=%s new=%s pending=%s', + old_state.value_name, new_state.value_name, + pending_state.value_name) - if new_state == gst.STATE_READY and pending_state == gst.STATE_NULL: + if new_state == Gst.State.READY and pending_state == Gst.State.NULL: # XXX: We're not called on the last state change when going down to # NULL, so we rewrite the second to last call to get the expected # behavior. - new_state = gst.STATE_NULL - pending_state = gst.STATE_VOID_PENDING + new_state = Gst.State.NULL + pending_state = Gst.State.VOID_PENDING - if pending_state != gst.STATE_VOID_PENDING: + if pending_state != Gst.State.VOID_PENDING: return # Ignore intermediate state changes - if new_state == gst.STATE_READY: + if new_state == Gst.State.READY: return # Ignore READY state as it's GStreamer specific new_state = _GST_STATE_MAPPING[new_state] @@ -299,80 +270,96 @@ class _Handler(object): AudioListener.send('stream_changed', uri=None) if 'GST_DEBUG_DUMP_DOT_DIR' in os.environ: - gst.DEBUG_BIN_TO_DOT_FILE( - self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy') + Gst.debug_bin_to_dot_file( + self._audio._playbin, Gst.DebugGraphDetails.ALL, 'mopidy') def on_buffering(self, percent, structure=None): - if structure and structure.has_field('buffering-mode'): - if structure['buffering-mode'] == gst.BUFFERING_LIVE: + if structure is not None and structure.has_field('buffering-mode'): + buffering_mode = structure.get_enum( + 'buffering-mode', Gst.BufferingMode) + if buffering_mode == Gst.BufferingMode.LIVE: return # Live sources stall in paused. level = logging.getLevelName('TRACE') if percent < 10 and not self._audio._buffering: - self._audio._playbin.set_state(gst.STATE_PAUSED) + self._audio._playbin.set_state(Gst.State.PAUSED) self._audio._buffering = True level = logging.DEBUG if percent == 100: self._audio._buffering = False - if self._audio._target_state == gst.STATE_PLAYING: - self._audio._playbin.set_state(gst.STATE_PLAYING) + if self._audio._target_state == Gst.State.PLAYING: + self._audio._playbin.set_state(Gst.State.PLAYING) level = logging.DEBUG - gst_logger.log(level, 'Got buffering message: percent=%d%%', percent) + gst_logger.log( + level, 'Got BUFFERING bus message: percent=%d%%', percent) def on_end_of_stream(self): - gst_logger.debug('Got end-of-stream message.') + gst_logger.debug('Got EOS (end of stream) bus message.') logger.debug('Audio event: reached_end_of_stream()') self._audio._tags = {} AudioListener.send('reached_end_of_stream') def on_error(self, error, debug): - gst_logger.error(str(error).decode('utf-8')) - if debug: - gst_logger.debug(debug.decode('utf-8')) + error_msg = str(error).decode('utf-8') + debug_msg = debug.decode('utf-8') + gst_logger.debug( + 'Got ERROR bus message: error=%r debug=%r', error_msg, debug_msg) + gst_logger.error('GStreamer error: %s', error_msg) # TODO: is this needed? self._audio.stop_playback() def on_warning(self, error, debug): - gst_logger.warning(str(error).decode('utf-8')) - if debug: - gst_logger.debug(debug.decode('utf-8')) + error_msg = str(error).decode('utf-8') + debug_msg = debug.decode('utf-8') + gst_logger.warning('GStreamer warning: %s', error_msg) + gst_logger.debug( + 'Got WARNING bus message: error=%r debug=%r', error_msg, debug_msg) def on_async_done(self): - gst_logger.debug('Got async-done.') + gst_logger.debug('Got ASYNC_DONE bus message.') def on_tag(self, taglist): - tags = utils.convert_taglist(taglist) + tags = tags_lib.convert_taglist(taglist) + gst_logger.debug('Got TAG bus message: tags=%r', dict(tags)) self._audio._tags.update(tags) logger.debug('Audio event: tags_changed(tags=%r)', tags.keys()) AudioListener.send('tags_changed', tags=tags.keys()) def on_missing_plugin(self, msg): - desc = gst.pbutils.missing_plugin_message_get_description(msg) - debug = gst.pbutils.missing_plugin_message_get_installer_detail(msg) - - gst_logger.debug('Got missing-plugin message: description:%s', desc) + desc = GstPbutils.missing_plugin_message_get_description(msg) + debug = GstPbutils.missing_plugin_message_get_installer_detail(msg) + gst_logger.debug( + 'Got missing-plugin bus message: description=%r', desc) logger.warning('Could not find a %s to handle media.', desc) - if gst.pbutils.install_plugins_supported(): + if GstPbutils.install_plugins_supported(): logger.info('You might be able to fix this by running: ' 'gst-installer "%s"', debug) # TODO: store the missing plugins installer info in a file so we can # can provide a 'mopidy install-missing-plugins' if the system has the # required helper installed? - def on_new_segment(self, update, rate, format_, start, stop, position): - gst_logger.debug('Got new-segment event: update=%s rate=%s format=%s ' - 'start=%s stop=%s position=%s', update, rate, - format_.value_name, start, stop, position) - position_ms = position // gst.MSECOND - logger.debug('Audio event: position_changed(position=%s)', position_ms) - AudioListener.send('position_changed', position=position_ms) - - def on_stream_changed(self, uri): - gst_logger.debug('Got stream-changed message: uri=%s', uri) - logger.debug('Audio event: stream_changed(uri=%s)', uri) + def on_stream_start(self): + gst_logger.debug('Got STREAM_START bus message') + uri = self._audio._pending_uri + logger.debug('Audio event: stream_changed(uri=%r)', uri) AudioListener.send('stream_changed', uri=uri) + def on_segment(self, segment): + gst_logger.debug( + 'Got SEGMENT pad event: ' + 'rate=%(rate)s format=%(format)s start=%(start)s stop=%(stop)s ' + 'position=%(position)s', { + 'rate': segment.rate, + 'format': Gst.Format.get_name(segment.format), + 'start': segment.start, + 'stop': segment.stop, + 'position': segment.position + }) + position_ms = segment.position // Gst.MSECOND + logger.debug('Audio event: position_changed(position=%r)', position_ms) + AudioListener.send('position_changed', position=position_ms) + # TODO: create a player class which replaces the actors internals class Audio(pykka.ThreadingActor): @@ -391,9 +378,10 @@ class Audio(pykka.ThreadingActor): super(Audio, self).__init__() self._config = config - self._target_state = gst.STATE_NULL + self._target_state = Gst.State.NULL self._buffering = False self._tags = {} + self._pending_uri = None self._playbin = None self._outputs = None @@ -401,7 +389,7 @@ class Audio(pykka.ThreadingActor): self._handler = _Handler(self) self._appsrc = _Appsrc() - self._signals = _Signals() + self._signals = utils.Signals() if mixer and self._config['audio']['mixer'] == 'software': self.mixer = SoftwareMixer(mixer) @@ -413,7 +401,7 @@ class Audio(pykka.ThreadingActor): self._setup_playbin() self._setup_outputs() self._setup_audio_sink() - except gobject.GError as ex: + except GObject.GError as ex: logger.exception(ex) process.exit_process() @@ -424,19 +412,18 @@ class Audio(pykka.ThreadingActor): def _setup_preferences(self): # TODO: move out of audio actor? # Fix for https://github.com/mopidy/mopidy/issues/604 - registry = gst.registry_get_default() - jacksink = registry.find_feature( - 'jackaudiosink', gst.TYPE_ELEMENT_FACTORY) + registry = Gst.Registry.get() + jacksink = registry.find_feature('jackaudiosink', Gst.ElementFactory) if jacksink: - jacksink.set_rank(gst.RANK_SECONDARY) + jacksink.set_rank(Gst.Rank.SECONDARY) def _setup_playbin(self): - playbin = gst.element_factory_make('playbin2') + playbin = Gst.ElementFactory.make('playbin') playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO # TODO: turn into config values... playbin.set_property('buffer-size', 5 << 20) # 5MB - playbin.set_property('buffer-duration', 5 * gst.SECOND) + playbin.set_property('buffer-duration', 5 * Gst.SECOND) self._signals.connect(playbin, 'source-setup', self._on_source_setup) self._signals.connect(playbin, 'about-to-finish', @@ -450,13 +437,13 @@ class Audio(pykka.ThreadingActor): self._handler.teardown_event_handling() self._signals.disconnect(self._playbin, 'about-to-finish') self._signals.disconnect(self._playbin, 'source-setup') - self._playbin.set_state(gst.STATE_NULL) + self._playbin.set_state(Gst.State.NULL) def _setup_outputs(self): # We don't want to use outputs for regular testing, so just install # an unsynced fakesink when someone asks for a 'testoutput'. if self._config['audio']['output'] == 'testoutput': - self._outputs = gst.element_factory_make('fakesink') + self._outputs = Gst.ElementFactory.make('fakesink') else: self._outputs = _Outputs() try: @@ -464,26 +451,25 @@ class Audio(pykka.ThreadingActor): except exceptions.AudioException: process.exit_process() # TODO: move this up the chain - self._handler.setup_event_handling(self._outputs.get_pad('sink')) + self._handler.setup_event_handling( + self._outputs.get_static_pad('sink')) def _setup_audio_sink(self): - audio_sink = gst.Bin('audio-sink') + audio_sink = Gst.ElementFactory.make('bin', 'audio-sink') - # Queue element to buy us time between the about to finish event and + # Queue element to buy us time between the about-to-finish event and # the actual switch, i.e. about to switch can block for longer thanks # to this queue. - # TODO: make the min-max values a setting? - queue = gst.element_factory_make('queue') - queue.set_property('max-size-buffers', 0) - queue.set_property('max-size-bytes', 0) - queue.set_property('max-size-time', 3 * gst.SECOND) - queue.set_property('min-threshold-time', 1 * gst.SECOND) + # TODO: See if settings should be set to minimize latency. Previous + # setting breaks appsrc, and settings before that broke on a few + # systems. So leave the default to play it safe. + queue = Gst.ElementFactory.make('queue') audio_sink.add(queue) audio_sink.add(self._outputs) if self.mixer: - volume = gst.element_factory_make('volume') + volume = Gst.ElementFactory.make('volume') audio_sink.add(volume) queue.link(volume) volume.link(self._outputs) @@ -491,7 +477,7 @@ class Audio(pykka.ThreadingActor): else: queue.link(self._outputs) - ghost_pad = gst.GhostPad('sink', queue.get_pad('sink')) + ghost_pad = Gst.GhostPad.new('sink', queue.get_static_pad('sink')) audio_sink.add_pad(ghost_pad) self._playbin.set_property('audio-sink', audio_sink) @@ -508,11 +494,12 @@ class Audio(pykka.ThreadingActor): gst_logger.debug('Got about-to-finish event.') if self._about_to_finish_callback: - logger.debug('Running about to finish callback.') + logger.debug('Running about-to-finish callback.') self._about_to_finish_callback() def _on_source_setup(self, element, source): - gst_logger.debug('Got source-setup: element=%s', source) + gst_logger.debug( + 'Got source-setup signal: element=%s', source.__class__.__name__) if source.get_factory().get_name() == 'appsrc': self._appsrc.configure(source) @@ -539,6 +526,7 @@ class Audio(pykka.ThreadingActor): current_volume = None self._tags = {} # TODO: add test for this somehow + self._pending_uri = uri self._playbin.set_property('uri', uri) if self.mixer is not None and current_volume is not None: @@ -563,8 +551,10 @@ class Audio(pykka.ThreadingActor): :type seek_data: callable which takes time position in ms """ self._appsrc.prepare( - gst.Caps(bytes(caps)), need_data, enough_data, seek_data) - self._playbin.set_property('uri', 'appsrc://') + Gst.Caps.from_string(caps), need_data, enough_data, seek_data) + uri = 'appsrc://' + self._pending_uri = uri + self._playbin.set_property('uri', uri) def emit_data(self, buffer_): """ @@ -579,7 +569,7 @@ class Audio(pykka.ThreadingActor): Returns :class:`True` if data was delivered. :param buffer_: buffer to pass to appsrc - :type buffer_: :class:`gst.Buffer` or :class:`None` + :type buffer_: :class:`Gst.Buffer` or :class:`None` :rtype: boolean """ return self._appsrc.push(buffer_) @@ -617,15 +607,16 @@ class Audio(pykka.ThreadingActor): :rtype: int """ - try: - gst_position = self._playbin.query_position(gst.FORMAT_TIME)[0] - return utils.clocktime_to_millisecond(gst_position) - except gst.QueryError: + success, position = self._playbin.query_position(Gst.Format.TIME) + + if not success: # TODO: take state into account for this and possibly also return # None as the unknown value instead of zero? logger.debug('Position query failed') return 0 + return utils.clocktime_to_millisecond(position) + def set_position(self, position): """ Set position in milliseconds. @@ -636,9 +627,9 @@ class Audio(pykka.ThreadingActor): """ # TODO: double check seek flags in use. gst_position = utils.millisecond_to_clocktime(position) + gst_logger.debug('Sending flushing seek: position=%r', gst_position) result = self._playbin.seek_simple( - gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, gst_position) - gst_logger.debug('Sent flushing seek: position=%s', gst_position) + Gst.Format.TIME, Gst.SeekFlags.FLUSH, gst_position) return result def start_playback(self): @@ -647,7 +638,7 @@ class Audio(pykka.ThreadingActor): :rtype: :class:`True` if successfull, else :class:`False` """ - return self._set_state(gst.STATE_PLAYING) + return self._set_state(Gst.State.PLAYING) def pause_playback(self): """ @@ -655,7 +646,7 @@ class Audio(pykka.ThreadingActor): :rtype: :class:`True` if successfull, else :class:`False` """ - return self._set_state(gst.STATE_PAUSED) + return self._set_state(Gst.State.PAUSED) def prepare_change(self): """ @@ -664,9 +655,9 @@ class Audio(pykka.ThreadingActor): This function *MUST* be called before changing URIs or doing changes like updating data that is being pushed. The reason for this is that GStreamer will reset all its state when it changes to - :attr:`gst.STATE_READY`. + :attr:`Gst.State.READY`. """ - return self._set_state(gst.STATE_READY) + return self._set_state(Gst.State.READY) def stop_playback(self): """ @@ -675,14 +666,14 @@ class Audio(pykka.ThreadingActor): :rtype: :class:`True` if successfull, else :class:`False` """ self._buffering = False - return self._set_state(gst.STATE_NULL) + return self._set_state(Gst.State.NULL) def wait_for_state_change(self): """Block until any pending state changes are complete. Should only be used by tests. """ - self._playbin.get_state() + self._playbin.get_state(timeout=Gst.CLOCK_TIME_NONE) def enable_sync_handler(self): """Enable manual processing of messages from bus. @@ -691,7 +682,7 @@ class Audio(pykka.ThreadingActor): """ def sync_handler(bus, message): self._handler.on_message(bus, message) - return gst.BUS_DROP + return Gst.BusSyncReply.DROP bus = self._playbin.get_bus() bus.set_sync_handler(sync_handler) @@ -712,17 +703,18 @@ class Audio(pykka.ThreadingActor): "READY" -> "NULL" "READY" -> "PAUSED" - :param state: State to set playbin to. One of: `gst.STATE_NULL`, - `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. - :type state: :class:`gst.State` + :param state: State to set playbin to. One of: `Gst.State.NULL`, + `Gst.State.READY`, `Gst.State.PAUSED` and `Gst.State.PLAYING`. + :type state: :class:`Gst.State` :rtype: :class:`True` if successfull, else :class:`False` """ self._target_state = state result = self._playbin.set_state(state) - gst_logger.debug('State change to %s: result=%s', state.value_name, - result.value_name) + gst_logger.debug( + 'Changing state to %s: result=%s', state.value_name, + result.value_name) - if result == gst.STATE_CHANGE_FAILURE: + if result == Gst.StateChangeReturn.FAILURE: logger.warning( 'Setting GStreamer state to %s failed', state.value_name) return False @@ -735,35 +727,45 @@ class Audio(pykka.ThreadingActor): """ Set track metadata for currently playing song. - Only needs to be called by sources such as `appsrc` which do not + Only needs to be called by sources such as ``appsrc`` which do not already inject tags in playbin, e.g. when using :meth:`emit_data` to deliver raw audio data to GStreamer. :param track: the current track :type track: :class:`mopidy.models.Track` """ - taglist = gst.TagList() + taglist = Gst.TagList.new_empty() artists = [a for a in (track.artists or []) if a.name] + def set_value(tag, value): + gobject_value = GObject.Value() + gobject_value.init(GObject.TYPE_STRING) + gobject_value.set_string(value) + taglist.add_value( + Gst.TagMergeMode.REPLACE, Gst.TAG_ARTIST, gobject_value) + # Default to blank data to trick shoutcast into clearing any previous # values it might have. - taglist[gst.TAG_ARTIST] = ' ' - taglist[gst.TAG_TITLE] = ' ' - taglist[gst.TAG_ALBUM] = ' ' + # TODO: Verify if this works at all, likely it doesn't. + set_value(Gst.TAG_ARTIST, ' ') + set_value(Gst.TAG_TITLE, ' ') + set_value(Gst.TAG_ALBUM, ' ') if artists: - taglist[gst.TAG_ARTIST] = ', '.join([a.name for a in artists]) + set_value(Gst.TAG_ARTIST, ', '.join([a.name for a in artists])) if track.name: - taglist[gst.TAG_TITLE] = track.name + set_value(Gst.TAG_TITLE, track.name) if track.album and track.album.name: - taglist[gst.TAG_ALBUM] = track.album.name + set_value(Gst.TAG_ALBUM, track.album.name) - event = gst.event_new_tag(taglist) + gst_logger.debug( + 'Sending TAG event for track %r: %r', + track.uri, taglist.to_string()) + event = Gst.Event.new_tag(taglist) # TODO: check if we get this back on our own bus? self._playbin.send_event(event) - gst_logger.debug('Sent tag event: track=%s', track.uri) def get_current_tags(self): """ diff --git a/mopidy/audio/icy.py b/mopidy/audio/icy.py deleted file mode 100644 index dd59baae..00000000 --- a/mopidy/audio/icy.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import gobject - -import pygst -pygst.require('0.10') -import gst # noqa - - -class IcySrc(gst.Bin, gst.URIHandler): - __gstdetails__ = ('IcySrc', - 'Src', - 'HTTP src wrapper for icy:// support.', - 'Mopidy') - - srcpad_template = gst.PadTemplate( - 'src', gst.PAD_SRC, gst.PAD_ALWAYS, - gst.caps_new_any()) - - __gsttemplates__ = (srcpad_template,) - - def __init__(self): - super(IcySrc, self).__init__() - self._httpsrc = gst.element_make_from_uri(gst.URI_SRC, 'http://') - try: - self._httpsrc.set_property('iradio-mode', True) - except TypeError: - pass - self.add(self._httpsrc) - - self._srcpad = gst.GhostPad('src', self._httpsrc.get_pad('src')) - self.add_pad(self._srcpad) - - @classmethod - def do_get_type_full(cls): - return gst.URI_SRC - - @classmethod - def do_get_protocols_full(cls): - return [b'icy', b'icyx'] - - def do_set_uri(self, uri): - if uri.startswith('icy://'): - return self._httpsrc.set_uri(b'http://' + uri[len('icy://'):]) - elif uri.startswith('icyx://'): - return self._httpsrc.set_uri(b'https://' + uri[len('icyx://'):]) - else: - return False - - def do_get_uri(self): - uri = self._httpsrc.get_uri() - if uri.startswith('http://'): - return b'icy://' + uri[len('http://'):] - else: - return b'icyx://' + uri[len('https://'):] - - -def register(): - # Only register icy if gst install can't handle it on it's own. - if not gst.element_make_from_uri(gst.URI_SRC, 'icy://'): - gobject.type_register(IcySrc) - gst.element_register( - IcySrc, IcySrc.__name__.lower(), gst.RANK_MARGINAL) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index a54120d1..c63405b0 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -2,21 +2,27 @@ from __future__ import ( absolute_import, division, print_function, unicode_literals) import collections - -import pygst -pygst.require('0.10') -import gst # noqa -import gst.pbutils # noqa +import time from mopidy import exceptions -from mopidy.audio import utils +from mopidy.audio import tags as tags_lib, utils from mopidy.internal import encoding +from mopidy.internal.gi import Gst, GstPbutils + +# GST_ELEMENT_FACTORY_LIST: +_DECODER = 1 << 0 +_AUDIO = 1 << 50 +_DEMUXER = 1 << 5 +_DEPAYLOADER = 1 << 8 +_PARSER = 1 << 6 + +# GST_TYPE_AUTOPLUG_SELECT_RESULT: +_SELECT_TRY = 0 +_SELECT_EXPOSE = 1 _Result = collections.namedtuple( 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable')) -_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') - # TODO: replace with a scan(uri, timeout=1000, proxy_config=None)? class Scanner(object): @@ -51,7 +57,7 @@ class Scanner(object): """ timeout = int(timeout or self._timeout_ms) tags, duration, seekable, mime = None, None, None, None - pipeline = _setup_pipeline(uri, self._proxy_config) + pipeline, signals = _setup_pipeline(uri, self._proxy_config) try: _start_pipeline(pipeline) @@ -59,7 +65,8 @@ class Scanner(object): duration = _query_duration(pipeline) seekable = _query_seekable(pipeline) finally: - pipeline.set_state(gst.STATE_NULL) + signals.clear() + pipeline.set_state(Gst.State.NULL) del pipeline return _Result(uri, tags, duration, seekable, mime, have_audio) @@ -68,117 +75,149 @@ class Scanner(object): # Turns out it's _much_ faster to just create a new pipeline for every as # decodebins and other elements don't seem to take well to being reused. def _setup_pipeline(uri, proxy_config=None): - src = gst.element_make_from_uri(gst.URI_SRC, uri) + src = Gst.Element.make_from_uri(Gst.URIType.SRC, uri) if not src: raise exceptions.ScannerError('GStreamer can not open: %s' % uri) - typefind = gst.element_factory_make('typefind') - decodebin = gst.element_factory_make('decodebin2') + typefind = Gst.ElementFactory.make('typefind') + decodebin = Gst.ElementFactory.make('decodebin') - pipeline = gst.element_factory_make('pipeline') + pipeline = Gst.ElementFactory.make('pipeline') for e in (src, typefind, decodebin): pipeline.add(e) - gst.element_link_many(src, typefind, decodebin) + src.link(typefind) + typefind.link(decodebin) if proxy_config: utils.setup_proxy(src, proxy_config) - typefind.connect('have-type', _have_type, decodebin) - decodebin.connect('pad-added', _pad_added, pipeline) + signals = utils.Signals() + signals.connect(typefind, 'have-type', _have_type, decodebin) + signals.connect(decodebin, 'pad-added', _pad_added, pipeline) + signals.connect(decodebin, 'autoplug-select', _autoplug_select) - return pipeline + return pipeline, signals def _have_type(element, probability, caps, decodebin): decodebin.set_property('sink-caps', caps) - struct = gst.Structure('have-type') - struct['caps'] = caps.get_structure(0) - element.get_bus().post(gst.message_new_application(element, struct)) + struct = Gst.Structure.new_empty('have-type') + struct.set_value('caps', caps.get_structure(0)) + element.get_bus().post(Gst.Message.new_application(element, struct)) def _pad_added(element, pad, pipeline): - sink = gst.element_factory_make('fakesink') + sink = Gst.ElementFactory.make('fakesink') sink.set_property('sync', False) pipeline.add(sink) sink.sync_state_with_parent() - pad.link(sink.get_pad('sink')) + pad.link(sink.get_static_pad('sink')) - if pad.get_caps().is_subset(_RAW_AUDIO): - struct = gst.Structure('have-audio') - element.get_bus().post(gst.message_new_application(element, struct)) + if pad.query_caps().is_subset(Gst.Caps.from_string('audio/x-raw')): + # Probably won't happen due to autoplug-select fix, but lets play it + # safe until we've tested more. + struct = Gst.Structure.new_empty('have-audio') + element.get_bus().post(Gst.Message.new_application(element, struct)) + + +def _autoplug_select(element, pad, caps, factory): + if factory.list_is_type(_DECODER | _AUDIO): + struct = Gst.Structure.new_empty('have-audio') + element.get_bus().post(Gst.Message.new_application(element, struct)) + if not factory.list_is_type(_DEMUXER | _DEPAYLOADER | _PARSER): + return _SELECT_EXPOSE + return _SELECT_TRY def _start_pipeline(pipeline): - if pipeline.set_state(gst.STATE_PAUSED) == gst.STATE_CHANGE_NO_PREROLL: - pipeline.set_state(gst.STATE_PLAYING) + result = pipeline.set_state(Gst.State.PAUSED) + if result == Gst.StateChangeReturn.NO_PREROLL: + pipeline.set_state(Gst.State.PLAYING) -def _query_duration(pipeline): - try: - duration = pipeline.query_duration(gst.FORMAT_TIME, None)[0] - except gst.QueryError: +def _query_duration(pipeline, timeout=100): + # 1. Try and get a duration, return if success. + # 2. Some formats need to play some buffers before duration is found. + # 3. Wait for a duration change event. + # 4. Try and get a duration again. + + success, duration = pipeline.query_duration(Gst.Format.TIME) + if success and duration >= 0: + return duration // Gst.MSECOND + + result = pipeline.set_state(Gst.State.PLAYING) + if result == Gst.StateChangeReturn.FAILURE: return None - if duration < 0: - return None - else: - return duration // gst.MSECOND + gst_timeout = timeout * Gst.MSECOND + bus = pipeline.get_bus() + bus.timed_pop_filtered(gst_timeout, Gst.MessageType.DURATION_CHANGED) + + success, duration = pipeline.query_duration(Gst.Format.TIME) + if success and duration >= 0: + return duration // Gst.MSECOND + return None def _query_seekable(pipeline): - query = gst.query_new_seeking(gst.FORMAT_TIME) + query = Gst.Query.new_seeking(Gst.Format.TIME) pipeline.query(query) return query.parse_seeking()[1] def _process(pipeline, timeout_ms): - clock = pipeline.get_clock() bus = pipeline.get_bus() - timeout = timeout_ms * gst.MSECOND tags = {} mime = None 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.MessageType.ELEMENT | + Gst.MessageType.APPLICATION | + Gst.MessageType.ERROR | + Gst.MessageType.EOS | + Gst.MessageType.ASYNC_DONE | + Gst.MessageType.TAG + ) - previous = clock.get_time() + timeout = timeout_ms + previous = int(time.time() * 1000) while timeout > 0: - message = bus.timed_pop_filtered(timeout, types) + message = bus.timed_pop_filtered(timeout * Gst.MSECOND, types) if message is None: break - elif message.type == gst.MESSAGE_ELEMENT: - if gst.pbutils.is_missing_plugin_message(message): + elif message.type == Gst.MessageType.ELEMENT: + if GstPbutils.is_missing_plugin_message(message): missing_message = message - elif message.type == gst.MESSAGE_APPLICATION: - if message.structure.get_name() == 'have-type': - mime = message.structure['caps'].get_name() - if mime.startswith('text/') or mime == 'application/xml': + elif message.type == Gst.MessageType.APPLICATION: + if message.get_structure().get_name() == 'have-type': + mime = message.get_structure().get_value('caps').get_name() + if mime and ( + mime.startswith('text/') or mime == 'application/xml'): return tags, mime, have_audio - elif message.structure.get_name() == 'have-audio': + elif message.get_structure().get_name() == 'have-audio': have_audio = True - elif message.type == gst.MESSAGE_ERROR: + elif message.type == Gst.MessageType.ERROR: error = encoding.locale_decode(message.parse_error()[0]) if missing_message and not mime: - caps = missing_message.structure['detail'] + caps = missing_message.get_structure().get_value('detail') mime = caps.get_structure(0).get_name() return tags, mime, have_audio raise exceptions.ScannerError(error) - elif message.type == gst.MESSAGE_EOS: + elif message.type == Gst.MessageType.EOS: return tags, mime, have_audio - elif message.type == gst.MESSAGE_ASYNC_DONE: + elif message.type == Gst.MessageType.ASYNC_DONE: if message.src == pipeline: return tags, mime, have_audio - elif message.type == gst.MESSAGE_TAG: + elif message.type == Gst.MessageType.TAG: taglist = message.parse_tag() # Note that this will only keep the last tag. - tags.update(utils.convert_taglist(taglist)) + tags.update(tags_lib.convert_taglist(taglist)) - now = clock.get_time() + now = int(time.time() * 1000) timeout -= now - previous previous = now @@ -189,15 +228,11 @@ if __name__ == '__main__': import os import sys - import gobject - from mopidy.internal import path - gobject.threads_init() - scanner = Scanner(5000) for uri in sys.argv[1:]: - if not gst.uri_is_valid(uri): + if not Gst.uri_is_valid(uri): uri = path.path_to_uri(os.path.abspath(uri)) try: result = scanner.scan(uri) diff --git a/mopidy/audio/tags.py b/mopidy/audio/tags.py new file mode 100644 index 00000000..62784bc0 --- /dev/null +++ b/mopidy/audio/tags.py @@ -0,0 +1,139 @@ +from __future__ import absolute_import, unicode_literals + +import collections +import datetime +import logging +import numbers + +from mopidy import compat +from mopidy.internal import log +from mopidy.internal.gi import GLib, Gst +from mopidy.models import Album, Artist, Track + + +logger = logging.getLogger(__name__) + + +def convert_taglist(taglist): + """Convert a :class:`Gst.TagList` to plain Python types. + + Knows how to convert: + + - Dates + - Buffers + - Numbers + - Strings + - Booleans + + Unknown types will be ignored and trace logged. Tag keys are all strings + defined as part GStreamer under GstTagList_. + + .. _GstTagList: https://developer.gnome.org/gstreamer/stable/\ +gstreamer-GstTagList.html + + :param taglist: A GStreamer taglist to be converted. + :type taglist: :class:`Gst.TagList` + :rtype: dictionary of tag keys with a list of values. + """ + result = collections.defaultdict(list) + + for n in range(taglist.n_tags()): + tag = taglist.nth_tag_name(n) + + for i in range(taglist.get_tag_size(tag)): + value = taglist.get_value_index(tag, i) + + if isinstance(value, GLib.Date): + date = datetime.date( + value.get_year(), value.get_month(), value.get_day()) + result[tag].append(date.isoformat().decode('utf-8')) + if isinstance(value, Gst.DateTime): + result[tag].append(value.to_iso8601_string().decode('utf-8')) + elif isinstance(value, bytes): + result[tag].append(value.decode('utf-8', 'replace')) + elif isinstance(value, (compat.text_type, bool, numbers.Number)): + result[tag].append(value) + else: + logger.log( + log.TRACE_LOG_LEVEL, + 'Ignoring unknown tag data: %r = %r', tag, value) + + return result + + +# TODO: split based on "stream" and "track" based conversion? i.e. handle data +# from radios in it's own helper instead? +def convert_tags_to_track(tags): + """Convert our normalized tags to a track. + + :param tags: dictionary of tag keys with a list of values + :type tags: :class:`dict` + :rtype: :class:`mopidy.models.Track` + """ + album_kwargs = {} + track_kwargs = {} + + track_kwargs['composers'] = _artists(tags, Gst.TAG_COMPOSER) + track_kwargs['performers'] = _artists(tags, Gst.TAG_PERFORMER) + track_kwargs['artists'] = _artists(tags, Gst.TAG_ARTIST, + 'musicbrainz-artistid', + 'musicbrainz-sortname') + album_kwargs['artists'] = _artists( + tags, Gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid') + + track_kwargs['genre'] = '; '.join(tags.get(Gst.TAG_GENRE, [])) + track_kwargs['name'] = '; '.join(tags.get(Gst.TAG_TITLE, [])) + if not track_kwargs['name']: + track_kwargs['name'] = '; '.join(tags.get(Gst.TAG_ORGANIZATION, [])) + + track_kwargs['comment'] = '; '.join(tags.get('comment', [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(Gst.TAG_LOCATION, [])) + if not track_kwargs['comment']: + track_kwargs['comment'] = '; '.join(tags.get(Gst.TAG_COPYRIGHT, [])) + + track_kwargs['track_no'] = tags.get(Gst.TAG_TRACK_NUMBER, [None])[0] + track_kwargs['disc_no'] = tags.get(Gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0] + track_kwargs['bitrate'] = tags.get(Gst.TAG_BITRATE, [None])[0] + track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0] + + album_kwargs['name'] = tags.get(Gst.TAG_ALBUM, [None])[0] + album_kwargs['num_tracks'] = tags.get(Gst.TAG_TRACK_COUNT, [None])[0] + album_kwargs['num_discs'] = tags.get(Gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] + album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] + + album_kwargs['date'] = tags.get(Gst.TAG_DATE, [None])[0] + if not album_kwargs['date']: + datetime = tags.get(Gst.TAG_DATE_TIME, [None])[0] + if datetime is not None: + album_kwargs['date'] = datetime.split('T')[0] + + # Clear out any empty values we found + track_kwargs = {k: v for k, v in track_kwargs.items() if v} + album_kwargs = {k: v for k, v in album_kwargs.items() if v} + + # Only bother with album if we have a name to show. + if album_kwargs.get('name'): + track_kwargs['album'] = Album(**album_kwargs) + + return Track(**track_kwargs) + + +def _artists( + tags, artist_name, artist_id=None, artist_sortname=None): + + # Name missing, don't set artist + if not tags.get(artist_name): + return None + # One artist name and either id or sortname, include all available fields + if len(tags[artist_name]) == 1 and \ + (artist_id in tags or artist_sortname in tags): + attrs = {'name': tags[artist_name][0]} + if artist_id in tags: + attrs['musicbrainz_id'] = tags[artist_id][0] + if artist_sortname in tags: + attrs['sortname'] = tags[artist_sortname][0] + return [Artist(**attrs)] + + # Multiple artist, provide artists with name only to avoid ambiguity. + return [Artist(name=name) for name in tags[artist_name]] diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index bc527df7..8bc5279d 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -1,50 +1,41 @@ from __future__ import absolute_import, unicode_literals -import datetime -import logging -import numbers - -import pygst -pygst.require('0.10') -import gst # noqa - -from mopidy import compat, httpclient -from mopidy.models import Album, Artist, Track - -logger = logging.getLogger(__name__) +from mopidy import httpclient +from mopidy.internal.gi import Gst def calculate_duration(num_samples, sample_rate): """Determine duration of samples using GStreamer helper for precise math.""" - return gst.util_uint64_scale(num_samples, gst.SECOND, sample_rate) + return Gst.util_uint64_scale(num_samples, Gst.SECOND, sample_rate) def create_buffer(data, capabilites=None, timestamp=None, duration=None): """Create a new GStreamer buffer based on provided data. Mainly intended to keep gst imports out of non-audio modules. + + .. versionchanged:: 1.2 + ``capabilites`` argument is no longer in use """ - buffer_ = gst.Buffer(data) - if capabilites: - if isinstance(capabilites, compat.string_types): - capabilites = gst.caps_from_string(capabilites) - buffer_.set_caps(capabilites) - if timestamp: - buffer_.timestamp = timestamp - if duration: + if not data: + raise ValueError('Cannot create buffer without data') + buffer_ = Gst.Buffer.new_wrapped(data) + if timestamp is not None: + buffer_.pts = timestamp + if duration is not None: buffer_.duration = duration return buffer_ def millisecond_to_clocktime(value): """Convert a millisecond time to internal GStreamer time.""" - return value * gst.MSECOND + return value * Gst.MSECOND def clocktime_to_millisecond(value): """Convert an internal GStreamer time to millisecond time.""" - return value // gst.MSECOND + return value // Gst.MSECOND def supported_uri_schemes(uri_schemes): @@ -55,9 +46,9 @@ def supported_uri_schemes(uri_schemes): :rtype: set of URI schemes we can support via this GStreamer install. """ supported_schemes = set() - registry = gst.registry_get_default() + registry = Gst.Registry.get() - for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY): + for factory in registry.get_feature_list(Gst.ElementFactory): for uri in factory.get_uri_protocols(): if uri in uri_schemes: supported_schemes.add(uri) @@ -65,84 +56,11 @@ def supported_uri_schemes(uri_schemes): return supported_schemes -def _artists(tags, artist_name, artist_id=None, artist_sortname=None): - # Name missing, don't set artist - if not tags.get(artist_name): - return None - # One artist name and either id or sortname, include all available fields - if len(tags[artist_name]) == 1 and \ - (artist_id in tags or artist_sortname in tags): - attrs = {'name': tags[artist_name][0]} - if artist_id in tags: - attrs['musicbrainz_id'] = tags[artist_id][0] - if artist_sortname in tags: - attrs['sortname'] = tags[artist_sortname][0] - return [Artist(**attrs)] - - # Multiple artist, provide artists with name only to avoid ambiguity. - return [Artist(name=name) for name in tags[artist_name]] - - -# TODO: split based on "stream" and "track" based conversion? i.e. handle data -# from radios in it's own helper instead? -def convert_tags_to_track(tags): - """Convert our normalized tags to a track. - - :param tags: dictionary of tag keys with a list of values - :type tags: :class:`dict` - :rtype: :class:`mopidy.models.Track` - """ - album_kwargs = {} - track_kwargs = {} - - track_kwargs['composers'] = _artists(tags, gst.TAG_COMPOSER) - track_kwargs['performers'] = _artists(tags, gst.TAG_PERFORMER) - track_kwargs['artists'] = _artists(tags, gst.TAG_ARTIST, - 'musicbrainz-artistid', - 'musicbrainz-sortname') - album_kwargs['artists'] = _artists( - tags, gst.TAG_ALBUM_ARTIST, 'musicbrainz-albumartistid') - - track_kwargs['genre'] = '; '.join(tags.get(gst.TAG_GENRE, [])) - track_kwargs['name'] = '; '.join(tags.get(gst.TAG_TITLE, [])) - if not track_kwargs['name']: - track_kwargs['name'] = '; '.join(tags.get(gst.TAG_ORGANIZATION, [])) - - track_kwargs['comment'] = '; '.join(tags.get('comment', [])) - if not track_kwargs['comment']: - track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_LOCATION, [])) - if not track_kwargs['comment']: - track_kwargs['comment'] = '; '.join(tags.get(gst.TAG_COPYRIGHT, [])) - - track_kwargs['track_no'] = tags.get(gst.TAG_TRACK_NUMBER, [None])[0] - track_kwargs['disc_no'] = tags.get(gst.TAG_ALBUM_VOLUME_NUMBER, [None])[0] - track_kwargs['bitrate'] = tags.get(gst.TAG_BITRATE, [None])[0] - track_kwargs['musicbrainz_id'] = tags.get('musicbrainz-trackid', [None])[0] - - album_kwargs['name'] = tags.get(gst.TAG_ALBUM, [None])[0] - album_kwargs['num_tracks'] = tags.get(gst.TAG_TRACK_COUNT, [None])[0] - album_kwargs['num_discs'] = tags.get(gst.TAG_ALBUM_VOLUME_COUNT, [None])[0] - album_kwargs['musicbrainz_id'] = tags.get('musicbrainz-albumid', [None])[0] - - if tags.get(gst.TAG_DATE) and tags.get(gst.TAG_DATE)[0]: - track_kwargs['date'] = tags[gst.TAG_DATE][0].isoformat() - - # Clear out any empty values we found - track_kwargs = {k: v for k, v in track_kwargs.items() if v} - album_kwargs = {k: v for k, v in album_kwargs.items() if v} - - # Only bother with album if we have a name to show. - if album_kwargs.get('name'): - track_kwargs['album'] = Album(**album_kwargs) - - return Track(**track_kwargs) - - def setup_proxy(element, config): """Configure a GStreamer element with proxy settings. :param element: element to setup proxy in. - :type element: :class:`gst.GstElement` + :type element: :class:`Gst.GstElement` :param config: proxy settings to use. :type config: :class:`dict` """ @@ -154,51 +72,31 @@ def setup_proxy(element, config): element.set_property('proxy-pw', config.get('password')) -def convert_taglist(taglist): - """Convert a :class:`gst.Taglist` to plain Python types. +class Signals(object): - Knows how to convert: + """Helper for tracking gobject signal registrations""" - - Dates - - Buffers - - Numbers - - Strings - - Booleans + def __init__(self): + self._ids = {} - Unknown types will be ignored and debug logged. Tag keys are all strings - defined as part GStreamer under GstTagList_. + def connect(self, element, event, func, *args): + """Connect a function + args to signal event on an element. - .. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\ -0.10.36/gstreamer/html/gstreamer-GstTagList.html + Each event may only be handled by one callback in this implementation. + """ + assert (element, event) not in self._ids + self._ids[(element, event)] = element.connect(event, func, *args) - :param taglist: A GStreamer taglist to be converted. - :type taglist: :class:`gst.Taglist` - :rtype: dictionary of tag keys with a list of values. - """ - result = {} + def disconnect(self, element, event): + """Disconnect whatever handler we have for an element+event pair. - # Taglists are not really dicts, hence the lack of .items() and - # explicit use of .keys() - for key in taglist.keys(): - result.setdefault(key, []) + Does nothing it the handler has already been removed. + """ + signal_id = self._ids.pop((element, event), None) + if signal_id is not None: + element.disconnect(signal_id) - values = taglist[key] - if not isinstance(values, list): - values = [values] - - for value in values: - if isinstance(value, gst.Date): - try: - date = datetime.date(value.year, value.month, value.day) - result[key].append(date) - except ValueError: - logger.debug('Ignoring invalid date: %r = %r', key, value) - elif isinstance(value, gst.Buffer): - result[key].append(bytes(value)) - elif isinstance( - value, (compat.string_types, bool, numbers.Number)): - result[key].append(value) - else: - logger.debug('Ignoring unknown data: %r = %r', key, value) - - return result + def clear(self): + """Clear all registered signal handlers.""" + for element, event in self._ids.keys(): + element.disconnect(self._ids.pop((element, event))) diff --git a/mopidy/commands.py b/mopidy/commands.py index e5adbc09..9565ada8 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -7,9 +7,7 @@ import logging import os import sys -import glib - -import gobject +from gi.repository import GLib, GObject import pykka @@ -21,7 +19,7 @@ from mopidy.internal import deps, process, timer, versioning logger = logging.getLogger(__name__) _default_config = [] -for base in glib.get_system_config_dirs() + (glib.get_user_config_dir(),): +for base in GLib.get_system_config_dirs() + [GLib.get_user_config_dir()]: _default_config.append(os.path.join(base, b'mopidy', b'mopidy.conf')) DEFAULT_CONFIG = b':'.join(_default_config) @@ -286,7 +284,7 @@ class RootCommand(Command): help='`section/key=value` values to override config options') def run(self, args, config): - loop = gobject.MainLoop() + loop = GObject.MainLoop() mixer_class = self.get_mixer_class(config, args.registry['mixer']) backend_classes = args.registry['backend'] diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 30064e5a..04fe0a7e 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/playback.py b/mopidy/core/playback.py index 2ae8dbb3..ea6d6569 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -486,7 +486,7 @@ class PlaybackController(object): if time_position < 0: time_position = 0 elif time_position > tl_track.track.length: - # TODO: gstreamer will trigger a about to finish for us, use that? + # TODO: GStreamer will trigger a about-to-finish for us, use that? self.next() return True diff --git a/mopidy/file/library.py b/mopidy/file/library.py index 20ac0632..09fa2cf1 100644 --- a/mopidy/file/library.py +++ b/mopidy/file/library.py @@ -7,7 +7,7 @@ import sys import urllib2 from mopidy import backend, exceptions, models -from mopidy.audio import scan, utils +from mopidy.audio import scan, tags from mopidy.internal import path @@ -83,7 +83,7 @@ class FileLibraryProvider(backend.LibraryProvider): try: result = self._scanner.scan(uri) - track = utils.convert_tags_to_track(result.tags).copy( + track = tags.convert_tags_to_track(result.tags).copy( uri=uri, length=result.duration) except exceptions.ScannerError as e: logger.warning('Failed looking up %s: %s', uri, e) diff --git a/mopidy/internal/deps.py b/mopidy/internal/deps.py index 1f363657..cc72d371 100644 --- a/mopidy/internal/deps.py +++ b/mopidy/internal/deps.py @@ -7,11 +7,8 @@ import sys import pkg_resources -import pygst -pygst.require('0.10') -import gst # noqa - from mopidy.internal import formatting +from mopidy.internal.gi import Gst, gi def format_dependency_list(adapters=None): @@ -110,8 +107,7 @@ def pkg_info(project_name=None, include_extras=False): def gstreamer_info(): other = [] - other.append('Python wrapper: gst-python %s' % ( - '.'.join(map(str, gst.get_pygst_version())))) + other.append('Python wrapper: python-gi %s' % gi.__version__) found_elements = [] missing_elements = [] @@ -135,8 +131,8 @@ def gstreamer_info(): return { 'name': 'GStreamer', - 'version': '.'.join(map(str, gst.get_gst_version())), - 'path': os.path.dirname(gst.__file__), + 'version': '.'.join(map(str, Gst.version())), + 'path': os.path.dirname(gi.__file__), 'other': '\n'.join(other), } @@ -187,6 +183,6 @@ def _gstreamer_check_elements(): ] known_elements = [ factory.get_name() for factory in - gst.registry_get_default().get_feature_list(gst.TYPE_ELEMENT_FACTORY)] + Gst.Registry.get().get_feature_list(Gst.ElementFactory)] return [ (element, element in known_elements) for element in elements_to_check] diff --git a/mopidy/internal/gi.py b/mopidy/internal/gi.py new file mode 100644 index 00000000..122d03b8 --- /dev/null +++ b/mopidy/internal/gi.py @@ -0,0 +1,42 @@ +from __future__ import absolute_import, print_function, unicode_literals + +import sys +import textwrap + + +try: + import gi + gi.require_version('Gst', '1.0') + gi.require_version('GstPbutils', '1.0') + from gi.repository import GLib, GObject, Gst, GstPbutils +except ImportError: + print(textwrap.dedent(""" + ERROR: A GObject Python package was not found. + + Mopidy requires GStreamer to work. GStreamer is a C library with a + number of dependencies itself, and cannot be installed with the regular + Python tools like pip. + + Please see http://docs.mopidy.com/en/latest/installation/ for + instructions on how to install the required dependencies. + """)) + raise +else: + Gst.is_initialized() or Gst.init() + + +REQUIRED_GST_VERSION = (1, 2) + +if Gst.version() < REQUIRED_GST_VERSION: + sys.exit( + 'ERROR: Mopidy requires GStreamer >= %s, but found %s.' % ( + '.'.join(map(str, REQUIRED_GST_VERSION)), Gst.version_string())) + + +__all__ = [ + 'GLib', + 'GObject', + 'Gst', + 'GstPbutils', + 'gi', +] diff --git a/mopidy/internal/network.py b/mopidy/internal/network.py index 4b8b35fe..c956d795 100644 --- a/mopidy/internal/network.py +++ b/mopidy/internal/network.py @@ -7,7 +7,7 @@ import socket import sys import threading -import gobject +from gi.repository import GObject import pykka @@ -67,7 +67,7 @@ def format_hostname(hostname): class Server(object): - """Setup listener and register it with gobject's event loop.""" + """Setup listener and register it with GObject's event loop.""" def __init__(self, host, port, protocol, protocol_kwargs=None, max_connections=5, timeout=30): @@ -87,7 +87,7 @@ class Server(object): return sock def register_server_socket(self, fileno): - gobject.io_add_watch(fileno, gobject.IO_IN, self.handle_connection) + GObject.io_add_watch(fileno, GObject.IO_IN, self.handle_connection) def handle_connection(self, fd, flags): try: @@ -132,7 +132,7 @@ class Server(object): class Connection(object): # NOTE: the callback code is _not_ run in the actor's thread, but in the # same one as the event loop. If code in the callbacks blocks, the rest of - # gobject code will likely be blocked as well... + # GObject code will likely be blocked as well... # # Also note that source_remove() return values are ignored on purpose, a # false return value would only tell us that what we thought was registered @@ -211,14 +211,14 @@ class Connection(object): return self.disable_timeout() - self.timeout_id = gobject.timeout_add_seconds( + self.timeout_id = GObject.timeout_add_seconds( self.timeout, self.timeout_callback) def disable_timeout(self): """Deactivate timeout mechanism.""" if self.timeout_id is None: return - gobject.source_remove(self.timeout_id) + GObject.source_remove(self.timeout_id) self.timeout_id = None def enable_recv(self): @@ -226,9 +226,9 @@ class Connection(object): return try: - self.recv_id = gobject.io_add_watch( + self.recv_id = GObject.io_add_watch( self.sock.fileno(), - gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, + GObject.IO_IN | GObject.IO_ERR | GObject.IO_HUP, self.recv_callback) except socket.error as e: self.stop('Problem with connection: %s' % e) @@ -236,7 +236,7 @@ class Connection(object): def disable_recv(self): if self.recv_id is None: return - gobject.source_remove(self.recv_id) + GObject.source_remove(self.recv_id) self.recv_id = None def enable_send(self): @@ -244,9 +244,9 @@ class Connection(object): return try: - self.send_id = gobject.io_add_watch( + self.send_id = GObject.io_add_watch( self.sock.fileno(), - gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, + GObject.IO_OUT | GObject.IO_ERR | GObject.IO_HUP, self.send_callback) except socket.error as e: self.stop('Problem with connection: %s' % e) @@ -255,11 +255,11 @@ class Connection(object): if self.send_id is None: return - gobject.source_remove(self.send_id) + GObject.source_remove(self.send_id) self.send_id = None def recv_callback(self, fd, flags): - if flags & (gobject.IO_ERR | gobject.IO_HUP): + if flags & (GObject.IO_ERR | GObject.IO_HUP): self.stop('Bad client flags: %s' % flags) return True @@ -283,7 +283,7 @@ class Connection(object): return True def send_callback(self, fd, flags): - if flags & (gobject.IO_ERR | gobject.IO_HUP): + if flags & (GObject.IO_ERR | GObject.IO_HUP): self.stop('Bad client flags: %s' % flags) return True diff --git a/mopidy/internal/playlists.py b/mopidy/internal/playlists.py index f8e654af..e80588c9 100644 --- a/mopidy/internal/playlists.py +++ b/mopidy/internal/playlists.py @@ -2,10 +2,6 @@ from __future__ import absolute_import, unicode_literals import io -import pygst -pygst.require('0.10') -import gst # noqa - from mopidy.compat import configparser from mopidy.internal import validation diff --git a/mopidy/internal/timer.py b/mopidy/internal/timer.py index b8dcb30d..7da02e55 100644 --- a/mopidy/internal/timer.py +++ b/mopidy/internal/timer.py @@ -4,13 +4,14 @@ import contextlib import logging import time +from mopidy.internal import log + logger = logging.getLogger(__name__) -TRACE = logging.getLevelName('TRACE') @contextlib.contextmanager -def time_logger(name, level=TRACE): +def time_logger(name, level=log.TRACE_LOG_LEVEL): start = time.time() yield logger.log(level, '%s took %dms', name, (time.time() - start) * 1000) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index d61cf441..ead874a0 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -6,7 +6,7 @@ import os import time from mopidy import commands, compat, exceptions -from mopidy.audio import scan, utils +from mopidy.audio import scan, tags from mopidy.internal import path from mopidy.local import translator @@ -140,18 +140,18 @@ class ScanCommand(commands.Command): relpath = translator.local_track_uri_to_path(uri, media_dir) file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) result = scanner.scan(file_uri) - tags, duration = result.tags, result.duration if not result.playable: logger.warning('Failed %s: No audio found in file.', uri) - elif duration < MIN_DURATION_MS: + elif result.duration < MIN_DURATION_MS: logger.warning('Failed %s: Track shorter than %dms', uri, MIN_DURATION_MS) else: mtime = file_mtimes.get(os.path.join(media_dir, relpath)) - track = utils.convert_tags_to_track(tags).replace( - uri=uri, length=duration, last_modified=mtime) + track = tags.convert_tags_to_track(result.tags).replace( + uri=uri, length=result.duration, last_modified=mtime) if library.add_supports_tags_and_duration: - library.add(track, tags=tags, duration=duration) + library.add( + track, tags=result.tags, duration=result.duration) else: library.add(track) logger.debug('Added %s', track.uri) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 6fc53f63..16842f59 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -42,11 +42,11 @@ def path_to_local_track_uri(relpath): URI.""" if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') - return b'local:track:%s' % urllib.quote(relpath) + return 'local:track:%s' % urllib.quote(relpath) def path_to_local_directory_uri(relpath): """Convert path relative to :confval:`local/media_dir` directory URI.""" if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') - return b'local:directory:%s' % urllib.quote(relpath) + return 'local:directory:%s' % urllib.quote(relpath) diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index 1e63d02f..f477a323 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -353,14 +353,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/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 9124d99a..7b943930 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 5f88b13b..c2e39652 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -8,7 +8,7 @@ import time import pykka from mopidy import audio as audio_lib, backend, exceptions, stream -from mopidy.audio import scan, utils +from mopidy.audio import scan, tags from mopidy.compat import urllib from mopidy.internal import http, playlists from mopidy.models import Track @@ -60,7 +60,7 @@ class StreamLibraryProvider(backend.LibraryProvider): try: result = self._scanner.scan(uri) - track = utils.convert_tags_to_track(result.tags).replace( + track = tags.convert_tags_to_track(result.tags).replace( uri=uri, length=result.duration) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 0cfbdaf3..2bcc792a 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -3,20 +3,14 @@ from __future__ import absolute_import, unicode_literals import threading import unittest -import gobject -gobject.threads_init() - import mock -import pygst -pygst.require('0.10') -import gst # noqa - import pykka from mopidy import audio from mopidy.audio.constants import PlaybackState from mopidy.internal import path +from mopidy.internal.gi import Gst from tests import dummy_audio, path_to_data_dir @@ -520,17 +514,17 @@ class AudioStateTest(unittest.TestCase): def test_state_does_not_change_when_in_gst_ready_state(self): self.audio._handler.on_playbin_state_changed( - gst.STATE_NULL, gst.STATE_READY, gst.STATE_VOID_PENDING) + Gst.State.NULL, Gst.State.READY, Gst.State.VOID_PENDING) self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) def test_state_changes_from_stopped_to_playing_on_play(self): self.audio._handler.on_playbin_state_changed( - gst.STATE_NULL, gst.STATE_READY, gst.STATE_PLAYING) + Gst.State.NULL, Gst.State.READY, Gst.State.PLAYING) self.audio._handler.on_playbin_state_changed( - gst.STATE_READY, gst.STATE_PAUSED, gst.STATE_PLAYING) + Gst.State.READY, Gst.State.PAUSED, Gst.State.PLAYING) self.audio._handler.on_playbin_state_changed( - gst.STATE_PAUSED, gst.STATE_PLAYING, gst.STATE_VOID_PENDING) + Gst.State.PAUSED, Gst.State.PLAYING, Gst.State.VOID_PENDING) self.assertEqual(audio.PlaybackState.PLAYING, self.audio.state) @@ -538,7 +532,7 @@ class AudioStateTest(unittest.TestCase): self.audio.state = audio.PlaybackState.PLAYING self.audio._handler.on_playbin_state_changed( - gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_VOID_PENDING) + Gst.State.PLAYING, Gst.State.PAUSED, Gst.State.VOID_PENDING) self.assertEqual(audio.PlaybackState.PAUSED, self.audio.state) @@ -546,12 +540,12 @@ class AudioStateTest(unittest.TestCase): self.audio.state = audio.PlaybackState.PLAYING self.audio._handler.on_playbin_state_changed( - gst.STATE_PLAYING, gst.STATE_PAUSED, gst.STATE_NULL) + Gst.State.PLAYING, Gst.State.PAUSED, Gst.State.NULL) self.audio._handler.on_playbin_state_changed( - gst.STATE_PAUSED, gst.STATE_READY, gst.STATE_NULL) + Gst.State.PAUSED, Gst.State.READY, Gst.State.NULL) # We never get the following call, so the logic must work without it # self.audio._handler.on_playbin_state_changed( - # gst.STATE_READY, gst.STATE_NULL, gst.STATE_VOID_PENDING) + # Gst.State.READY, Gst.State.NULL, Gst.State.VOID_PENDING) self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) @@ -565,17 +559,17 @@ class AudioBufferingTest(unittest.TestCase): def test_pause_when_buffer_empty(self): playbin = self.audio._playbin self.audio.start_playback() - playbin.set_state.assert_called_with(gst.STATE_PLAYING) + playbin.set_state.assert_called_with(Gst.State.PLAYING) playbin.set_state.reset_mock() self.audio._handler.on_buffering(0) - playbin.set_state.assert_called_with(gst.STATE_PAUSED) + playbin.set_state.assert_called_with(Gst.State.PAUSED) self.assertTrue(self.audio._buffering) def test_stay_paused_when_buffering_finished(self): playbin = self.audio._playbin self.audio.pause_playback() - playbin.set_state.assert_called_with(gst.STATE_PAUSED) + playbin.set_state.assert_called_with(Gst.State.PAUSED) playbin.set_state.reset_mock() self.audio._handler.on_buffering(100) @@ -585,11 +579,11 @@ class AudioBufferingTest(unittest.TestCase): def test_change_to_paused_while_buffering(self): playbin = self.audio._playbin self.audio.start_playback() - playbin.set_state.assert_called_with(gst.STATE_PLAYING) + playbin.set_state.assert_called_with(Gst.State.PLAYING) playbin.set_state.reset_mock() self.audio._handler.on_buffering(0) - playbin.set_state.assert_called_with(gst.STATE_PAUSED) + playbin.set_state.assert_called_with(Gst.State.PAUSED) self.audio.pause_playback() playbin.set_state.reset_mock() @@ -600,13 +594,13 @@ class AudioBufferingTest(unittest.TestCase): def test_change_to_stopped_while_buffering(self): playbin = self.audio._playbin self.audio.start_playback() - playbin.set_state.assert_called_with(gst.STATE_PLAYING) + playbin.set_state.assert_called_with(Gst.State.PLAYING) playbin.set_state.reset_mock() self.audio._handler.on_buffering(0) - playbin.set_state.assert_called_with(gst.STATE_PAUSED) + playbin.set_state.assert_called_with(Gst.State.PAUSED) playbin.set_state.reset_mock() self.audio.stop_playback() - playbin.set_state.assert_called_with(gst.STATE_NULL) + playbin.set_state.assert_called_with(Gst.State.NULL) self.assertFalse(self.audio._buffering) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 8c2b9af3..411ce805 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -3,9 +3,6 @@ from __future__ import absolute_import, unicode_literals import os import unittest -import gobject -gobject.threads_init() - from mopidy import exceptions from mopidy.audio import scan from mopidy.internal import path as path_lib diff --git a/tests/audio/test_tags.py b/tests/audio/test_tags.py new file mode 100644 index 00000000..01475124 --- /dev/null +++ b/tests/audio/test_tags.py @@ -0,0 +1,333 @@ +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals + +import unittest + +from mopidy import compat +from mopidy.audio import tags +from mopidy.internal.gi import GLib, GObject, Gst +from mopidy.models import Album, Artist, Track + + +class TestConvertTaglist(object): + + def make_taglist(self, tag, values): + taglist = Gst.TagList.new_empty() + + for value in values: + if isinstance(value, (GLib.Date, Gst.DateTime)): + taglist.add_value(Gst.TagMergeMode.APPEND, tag, value) + continue + + gobject_value = GObject.Value() + if isinstance(value, bytes): + gobject_value.init(GObject.TYPE_STRING) + gobject_value.set_string(value) + elif isinstance(value, int): + gobject_value.init(GObject.TYPE_UINT) + gobject_value.set_uint(value) + gobject_value.init(GObject.TYPE_VALUE) + gobject_value.set_value(value) + else: + raise TypeError + taglist.add_value(Gst.TagMergeMode.APPEND, tag, gobject_value) + + return taglist + + def test_date_tag(self): + date = GLib.Date.new_dmy(7, 1, 2014) + taglist = self.make_taglist(Gst.TAG_DATE, [date]) + + result = tags.convert_taglist(taglist) + + assert isinstance(result[Gst.TAG_DATE][0], compat.text_type) + assert result[Gst.TAG_DATE][0] == '2014-01-07' + + def test_date_time_tag(self): + taglist = self.make_taglist(Gst.TAG_DATE_TIME, [ + Gst.DateTime.new_from_iso8601_string(b'2014-01-07 14:13:12') + ]) + + result = tags.convert_taglist(taglist) + + assert isinstance(result[Gst.TAG_DATE_TIME][0], compat.text_type) + assert result[Gst.TAG_DATE_TIME][0] == '2014-01-07T14:13:12Z' + + def test_string_tag(self): + taglist = self.make_taglist(Gst.TAG_ARTIST, [b'ABBA', b'ACDC']) + + result = tags.convert_taglist(taglist) + + assert isinstance(result[Gst.TAG_ARTIST][0], compat.text_type) + assert result[Gst.TAG_ARTIST][0] == 'ABBA' + assert isinstance(result[Gst.TAG_ARTIST][1], compat.text_type) + assert result[Gst.TAG_ARTIST][1] == 'ACDC' + + def test_integer_tag(self): + taglist = self.make_taglist(Gst.TAG_BITRATE, [17]) + + result = tags.convert_taglist(taglist) + + assert result[Gst.TAG_BITRATE][0] == 17 + + +# TODO: keep ids without name? +# TODO: current test is trying to test everything at once with a complete tags +# set, instead we might want to try with a minimal one making testing easier. +class TagsToTrackTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.tags = { + 'album': ['album'], + 'track-number': [1], + 'artist': ['artist'], + 'composer': ['composer'], + 'performer': ['performer'], + 'album-artist': ['albumartist'], + 'title': ['track'], + 'track-count': [2], + 'album-disc-number': [2], + 'album-disc-count': [3], + 'date': ['2006-01-01'], + 'container-format': ['ID3 tag'], + 'genre': ['genre'], + 'comment': ['comment'], + 'musicbrainz-trackid': ['trackid'], + 'musicbrainz-albumid': ['albumid'], + 'musicbrainz-artistid': ['artistid'], + 'musicbrainz-sortname': ['sortname'], + 'musicbrainz-albumartistid': ['albumartistid'], + 'bitrate': [1000], + } + + artist = Artist(name='artist', musicbrainz_id='artistid', + sortname='sortname') + composer = Artist(name='composer') + performer = Artist(name='performer') + albumartist = Artist(name='albumartist', + musicbrainz_id='albumartistid') + + album = Album(name='album', date='2006-01-01', + num_tracks=2, num_discs=3, + musicbrainz_id='albumid', artists=[albumartist]) + + self.track = Track(name='track', + genre='genre', track_no=1, disc_no=2, + comment='comment', musicbrainz_id='trackid', + album=album, bitrate=1000, artists=[artist], + composers=[composer], performers=[performer]) + + def check(self, expected): + actual = tags.convert_tags_to_track(self.tags) + self.assertEqual(expected, actual) + + def test_track(self): + self.check(self.track) + + def test_missing_track_no(self): + del self.tags['track-number'] + self.check(self.track.replace(track_no=None)) + + def test_multiple_track_no(self): + self.tags['track-number'].append(9) + self.check(self.track) + + def test_missing_track_disc_no(self): + del self.tags['album-disc-number'] + self.check(self.track.replace(disc_no=None)) + + def test_multiple_track_disc_no(self): + self.tags['album-disc-number'].append(9) + self.check(self.track) + + def test_missing_track_name(self): + del self.tags['title'] + self.check(self.track.replace(name=None)) + + def test_multiple_track_name(self): + self.tags['title'] = ['name1', 'name2'] + self.check(self.track.replace(name='name1; name2')) + + def test_missing_track_musicbrainz_id(self): + del self.tags['musicbrainz-trackid'] + self.check(self.track.replace(musicbrainz_id=None)) + + def test_multiple_track_musicbrainz_id(self): + self.tags['musicbrainz-trackid'].append('id') + self.check(self.track) + + def test_missing_track_bitrate(self): + del self.tags['bitrate'] + self.check(self.track.replace(bitrate=None)) + + def test_multiple_track_bitrate(self): + self.tags['bitrate'].append(1234) + self.check(self.track) + + def test_missing_track_genre(self): + del self.tags['genre'] + self.check(self.track.replace(genre=None)) + + def test_multiple_track_genre(self): + self.tags['genre'] = ['genre1', 'genre2'] + self.check(self.track.replace(genre='genre1; genre2')) + + def test_missing_track_date(self): + del self.tags['date'] + self.check( + self.track.replace(album=self.track.album.replace(date=None))) + + def test_multiple_track_date(self): + self.tags['date'].append('2030-01-01') + self.check(self.track) + + def test_datetime_instead_of_date(self): + del self.tags['date'] + self.tags['datetime'] = ['2006-01-01T14:13:12Z'] + self.check(self.track) + + def test_missing_track_comment(self): + del self.tags['comment'] + self.check(self.track.replace(comment=None)) + + def test_multiple_track_comment(self): + self.tags['comment'] = ['comment1', 'comment2'] + self.check(self.track.replace(comment='comment1; comment2')) + + def test_missing_track_artist_name(self): + del self.tags['artist'] + self.check(self.track.replace(artists=[])) + + def test_multiple_track_artist_name(self): + self.tags['artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + self.check(self.track.replace(artists=artists)) + + def test_missing_track_artist_musicbrainz_id(self): + del self.tags['musicbrainz-artistid'] + artist = list(self.track.artists)[0].replace(musicbrainz_id=None) + self.check(self.track.replace(artists=[artist])) + + def test_multiple_track_artist_musicbrainz_id(self): + self.tags['musicbrainz-artistid'].append('id') + self.check(self.track) + + def test_missing_track_composer_name(self): + del self.tags['composer'] + self.check(self.track.replace(composers=[])) + + def test_multiple_track_composer_name(self): + self.tags['composer'] = ['composer1', 'composer2'] + composers = [Artist(name='composer1'), Artist(name='composer2')] + self.check(self.track.replace(composers=composers)) + + def test_missing_track_performer_name(self): + del self.tags['performer'] + self.check(self.track.replace(performers=[])) + + def test_multiple_track_performe_name(self): + self.tags['performer'] = ['performer1', 'performer2'] + performers = [Artist(name='performer1'), Artist(name='performer2')] + self.check(self.track.replace(performers=performers)) + + def test_missing_album_name(self): + del self.tags['album'] + self.check(self.track.replace(album=None)) + + def test_multiple_album_name(self): + self.tags['album'].append('album2') + self.check(self.track) + + def test_missing_album_musicbrainz_id(self): + del self.tags['musicbrainz-albumid'] + album = self.track.album.replace(musicbrainz_id=None, + images=[]) + self.check(self.track.replace(album=album)) + + def test_multiple_album_musicbrainz_id(self): + self.tags['musicbrainz-albumid'].append('id') + self.check(self.track) + + def test_missing_album_num_tracks(self): + del self.tags['track-count'] + album = self.track.album.replace(num_tracks=None) + self.check(self.track.replace(album=album)) + + def test_multiple_album_num_tracks(self): + self.tags['track-count'].append(9) + self.check(self.track) + + def test_missing_album_num_discs(self): + del self.tags['album-disc-count'] + album = self.track.album.replace(num_discs=None) + self.check(self.track.replace(album=album)) + + def test_multiple_album_num_discs(self): + self.tags['album-disc-count'].append(9) + self.check(self.track) + + def test_missing_album_artist_name(self): + del self.tags['album-artist'] + album = self.track.album.replace(artists=[]) + self.check(self.track.replace(album=album)) + + def test_multiple_album_artist_name(self): + self.tags['album-artist'] = ['name1', 'name2'] + artists = [Artist(name='name1'), Artist(name='name2')] + album = self.track.album.replace(artists=artists) + self.check(self.track.replace(album=album)) + + def test_missing_album_artist_musicbrainz_id(self): + del self.tags['musicbrainz-albumartistid'] + albumartist = list(self.track.album.artists)[0] + albumartist = albumartist.replace(musicbrainz_id=None) + album = self.track.album.replace(artists=[albumartist]) + self.check(self.track.replace(album=album)) + + def test_multiple_album_artist_musicbrainz_id(self): + self.tags['musicbrainz-albumartistid'].append('id') + self.check(self.track) + + def test_stream_organization_track_name(self): + del self.tags['title'] + self.tags['organization'] = ['organization'] + self.check(self.track.replace(name='organization')) + + def test_multiple_organization_track_name(self): + del self.tags['title'] + self.tags['organization'] = ['organization1', 'organization2'] + self.check(self.track.replace(name='organization1; organization2')) + + # TODO: combine all comment types? + def test_stream_location_track_comment(self): + del self.tags['comment'] + self.tags['location'] = ['location'] + self.check(self.track.replace(comment='location')) + + def test_multiple_location_track_comment(self): + del self.tags['comment'] + self.tags['location'] = ['location1', 'location2'] + self.check(self.track.replace(comment='location1; location2')) + + def test_stream_copyright_track_comment(self): + del self.tags['comment'] + self.tags['copyright'] = ['copyright'] + self.check(self.track.replace(comment='copyright')) + + def test_multiple_copyright_track_comment(self): + del self.tags['comment'] + self.tags['copyright'] = ['copyright1', 'copyright2'] + self.check(self.track.replace(comment='copyright1; copyright2')) + + def test_sortname(self): + self.tags['musicbrainz-sortname'] = ['another_sortname'] + artist = Artist(name='artist', sortname='another_sortname', + musicbrainz_id='artistid') + self.check(self.track.replace(artists=[artist])) + + def test_missing_sortname(self): + del self.tags['musicbrainz-sortname'] + artist = Artist(name='artist', sortname=None, + musicbrainz_id='artistid') + self.check(self.track.replace(artists=[artist])) diff --git a/tests/audio/test_utils.py b/tests/audio/test_utils.py index 0b497dad..99c99eb6 100644 --- a/tests/audio/test_utils.py +++ b/tests/audio/test_utils.py @@ -1,261 +1,23 @@ from __future__ import absolute_import, unicode_literals -import datetime -import unittest +import pytest from mopidy.audio import utils -from mopidy.models import Album, Artist, Track +from mopidy.internal.gi import Gst -# TODO: keep ids without name? -# TODO: current test is trying to test everything at once with a complete tags -# set, instead we might want to try with a minimal one making testing easier. -class TagsToTrackTest(unittest.TestCase): +class TestCreateBuffer(object): - def setUp(self): # noqa: N802 - self.tags = { - 'album': ['album'], - 'track-number': [1], - 'artist': ['artist'], - 'composer': ['composer'], - 'performer': ['performer'], - 'album-artist': ['albumartist'], - 'title': ['track'], - 'track-count': [2], - 'album-disc-number': [2], - 'album-disc-count': [3], - 'date': [datetime.date(2006, 1, 1,)], - 'container-format': ['ID3 tag'], - 'genre': ['genre'], - 'comment': ['comment'], - 'musicbrainz-trackid': ['trackid'], - 'musicbrainz-albumid': ['albumid'], - 'musicbrainz-artistid': ['artistid'], - 'musicbrainz-sortname': ['sortname'], - 'musicbrainz-albumartistid': ['albumartistid'], - 'bitrate': [1000], - } + def test_creates_buffer(self): + buf = utils.create_buffer(b'123', timestamp=0, duration=1000000) - artist = Artist(name='artist', musicbrainz_id='artistid', - sortname='sortname') - composer = Artist(name='composer') - performer = Artist(name='performer') - albumartist = Artist(name='albumartist', - musicbrainz_id='albumartistid') + assert isinstance(buf, Gst.Buffer) + assert buf.pts == 0 + assert buf.duration == 1000000 + assert buf.get_size() == len(b'123') - album = Album(name='album', num_tracks=2, num_discs=3, - musicbrainz_id='albumid', artists=[albumartist]) + def test_fails_if_data_has_zero_length(self): + with pytest.raises(ValueError) as excinfo: + utils.create_buffer(b'', timestamp=0, duration=1000000) - self.track = Track(name='track', date='2006-01-01', - genre='genre', track_no=1, disc_no=2, - comment='comment', musicbrainz_id='trackid', - album=album, bitrate=1000, artists=[artist], - composers=[composer], performers=[performer]) - - def check(self, expected): - actual = utils.convert_tags_to_track(self.tags) - self.assertEqual(expected, actual) - - def test_track(self): - self.check(self.track) - - def test_missing_track_no(self): - del self.tags['track-number'] - self.check(self.track.replace(track_no=None)) - - def test_multiple_track_no(self): - self.tags['track-number'].append(9) - self.check(self.track) - - def test_missing_track_disc_no(self): - del self.tags['album-disc-number'] - self.check(self.track.replace(disc_no=None)) - - def test_multiple_track_disc_no(self): - self.tags['album-disc-number'].append(9) - self.check(self.track) - - def test_missing_track_name(self): - del self.tags['title'] - self.check(self.track.replace(name=None)) - - def test_multiple_track_name(self): - self.tags['title'] = ['name1', 'name2'] - self.check(self.track.replace(name='name1; name2')) - - def test_missing_track_musicbrainz_id(self): - del self.tags['musicbrainz-trackid'] - self.check(self.track.replace(musicbrainz_id=None)) - - def test_multiple_track_musicbrainz_id(self): - self.tags['musicbrainz-trackid'].append('id') - self.check(self.track) - - def test_missing_track_bitrate(self): - del self.tags['bitrate'] - self.check(self.track.replace(bitrate=None)) - - def test_multiple_track_bitrate(self): - self.tags['bitrate'].append(1234) - self.check(self.track) - - def test_missing_track_genre(self): - del self.tags['genre'] - self.check(self.track.replace(genre=None)) - - def test_multiple_track_genre(self): - self.tags['genre'] = ['genre1', 'genre2'] - self.check(self.track.replace(genre='genre1; genre2')) - - def test_missing_track_date(self): - del self.tags['date'] - self.check(self.track.replace(date=None)) - - def test_multiple_track_date(self): - self.tags['date'].append(datetime.date(2030, 1, 1)) - self.check(self.track) - - def test_missing_track_comment(self): - del self.tags['comment'] - self.check(self.track.replace(comment=None)) - - def test_multiple_track_comment(self): - self.tags['comment'] = ['comment1', 'comment2'] - self.check(self.track.replace(comment='comment1; comment2')) - - def test_missing_track_artist_name(self): - del self.tags['artist'] - self.check(self.track.replace(artists=[])) - - def test_multiple_track_artist_name(self): - self.tags['artist'] = ['name1', 'name2'] - artists = [Artist(name='name1'), Artist(name='name2')] - self.check(self.track.replace(artists=artists)) - - def test_missing_track_artist_musicbrainz_id(self): - del self.tags['musicbrainz-artistid'] - artist = list(self.track.artists)[0].replace(musicbrainz_id=None) - self.check(self.track.replace(artists=[artist])) - - def test_multiple_track_artist_musicbrainz_id(self): - self.tags['musicbrainz-artistid'].append('id') - self.check(self.track) - - def test_missing_track_composer_name(self): - del self.tags['composer'] - self.check(self.track.replace(composers=[])) - - def test_multiple_track_composer_name(self): - self.tags['composer'] = ['composer1', 'composer2'] - composers = [Artist(name='composer1'), Artist(name='composer2')] - self.check(self.track.replace(composers=composers)) - - def test_missing_track_performer_name(self): - del self.tags['performer'] - self.check(self.track.replace(performers=[])) - - def test_multiple_track_performe_name(self): - self.tags['performer'] = ['performer1', 'performer2'] - performers = [Artist(name='performer1'), Artist(name='performer2')] - self.check(self.track.replace(performers=performers)) - - def test_missing_album_name(self): - del self.tags['album'] - self.check(self.track.replace(album=None)) - - def test_multiple_album_name(self): - self.tags['album'].append('album2') - self.check(self.track) - - def test_missing_album_musicbrainz_id(self): - del self.tags['musicbrainz-albumid'] - album = self.track.album.replace(musicbrainz_id=None, - images=[]) - self.check(self.track.replace(album=album)) - - def test_multiple_album_musicbrainz_id(self): - self.tags['musicbrainz-albumid'].append('id') - self.check(self.track) - - def test_missing_album_num_tracks(self): - del self.tags['track-count'] - album = self.track.album.replace(num_tracks=None) - self.check(self.track.replace(album=album)) - - def test_multiple_album_num_tracks(self): - self.tags['track-count'].append(9) - self.check(self.track) - - def test_missing_album_num_discs(self): - del self.tags['album-disc-count'] - album = self.track.album.replace(num_discs=None) - self.check(self.track.replace(album=album)) - - def test_multiple_album_num_discs(self): - self.tags['album-disc-count'].append(9) - self.check(self.track) - - def test_missing_album_artist_name(self): - del self.tags['album-artist'] - album = self.track.album.replace(artists=[]) - self.check(self.track.replace(album=album)) - - def test_multiple_album_artist_name(self): - self.tags['album-artist'] = ['name1', 'name2'] - artists = [Artist(name='name1'), Artist(name='name2')] - album = self.track.album.replace(artists=artists) - self.check(self.track.replace(album=album)) - - def test_missing_album_artist_musicbrainz_id(self): - del self.tags['musicbrainz-albumartistid'] - albumartist = list(self.track.album.artists)[0] - albumartist = albumartist.replace(musicbrainz_id=None) - album = self.track.album.replace(artists=[albumartist]) - self.check(self.track.replace(album=album)) - - def test_multiple_album_artist_musicbrainz_id(self): - self.tags['musicbrainz-albumartistid'].append('id') - self.check(self.track) - - def test_stream_organization_track_name(self): - del self.tags['title'] - self.tags['organization'] = ['organization'] - self.check(self.track.replace(name='organization')) - - def test_multiple_organization_track_name(self): - del self.tags['title'] - self.tags['organization'] = ['organization1', 'organization2'] - self.check(self.track.replace(name='organization1; organization2')) - - # TODO: combine all comment types? - def test_stream_location_track_comment(self): - del self.tags['comment'] - self.tags['location'] = ['location'] - self.check(self.track.replace(comment='location')) - - def test_multiple_location_track_comment(self): - del self.tags['comment'] - self.tags['location'] = ['location1', 'location2'] - self.check(self.track.replace(comment='location1; location2')) - - def test_stream_copyright_track_comment(self): - del self.tags['comment'] - self.tags['copyright'] = ['copyright'] - self.check(self.track.replace(comment='copyright')) - - def test_multiple_copyright_track_comment(self): - del self.tags['comment'] - self.tags['copyright'] = ['copyright1', 'copyright2'] - self.check(self.track.replace(comment='copyright1; copyright2')) - - def test_sortname(self): - self.tags['musicbrainz-sortname'] = ['another_sortname'] - artist = Artist(name='artist', sortname='another_sortname', - musicbrainz_id='artistid') - self.check(self.track.replace(artists=[artist])) - - def test_missing_sortname(self): - del self.tags['musicbrainz-sortname'] - artist = Artist(name='artist', sortname=None, - musicbrainz_id='artistid') - self.check(self.track.replace(artists=[artist])) + assert 'Cannot create buffer without data' in str(excinfo.value) 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 96528983..03960b24 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -232,7 +232,7 @@ class TestPreviousHandling(BaseTest): self.assertIn(tl_tracks[1], self.core.tracklist.tl_tracks) -class TestPlayUnknownHanlding(BaseTest): +class TestPlayUnknownHandling(BaseTest): tracks = [Track(uri='unknown:a', length=1234), Track(uri='dummy:b', length=1234)] @@ -264,7 +264,7 @@ class TestConsumeHandling(BaseTest): tl_track = self.core.tracklist.get_tl_tracks()[0] self.core.playback.play(tl_track) - self.core.tracklist.consume = True + self.core.tracklist.set_consume(True) self.replay_events() self.core.playback.next() diff --git a/tests/internal/network/test_connection.py b/tests/internal/network/test_connection.py index 8ae7d15c..291bbc46 100644 --- a/tests/internal/network/test_connection.py +++ b/tests/internal/network/test_connection.py @@ -5,7 +5,7 @@ import logging import socket import unittest -import gobject +from gi.repository import GObject from mock import Mock, call, patch, sentinel @@ -162,27 +162,27 @@ class ConnectionTest(unittest.TestCase): network.Connection.stop(self.mock, sentinel.reason) network.logger.log(any_int, any_unicode) - @patch.object(gobject, 'io_add_watch', new=Mock()) + @patch.object(GObject, 'io_add_watch', new=Mock()) def test_enable_recv_registers_with_gobject(self): self.mock.recv_id = None self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.fileno.return_value = sentinel.fileno - gobject.io_add_watch.return_value = sentinel.tag + GObject.io_add_watch.return_value = sentinel.tag network.Connection.enable_recv(self.mock) - gobject.io_add_watch.assert_called_once_with( + GObject.io_add_watch.assert_called_once_with( sentinel.fileno, - gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, + GObject.IO_IN | GObject.IO_ERR | GObject.IO_HUP, self.mock.recv_callback) self.assertEqual(sentinel.tag, self.mock.recv_id) - @patch.object(gobject, 'io_add_watch', new=Mock()) + @patch.object(GObject, 'io_add_watch', new=Mock()) def test_enable_recv_already_registered(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.recv_id = sentinel.tag network.Connection.enable_recv(self.mock) - self.assertEqual(0, gobject.io_add_watch.call_count) + self.assertEqual(0, GObject.io_add_watch.call_count) def test_enable_recv_does_not_change_tag(self): self.mock.recv_id = sentinel.tag @@ -191,20 +191,20 @@ class ConnectionTest(unittest.TestCase): network.Connection.enable_recv(self.mock) self.assertEqual(sentinel.tag, self.mock.recv_id) - @patch.object(gobject, 'source_remove', new=Mock()) + @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_recv_deregisters(self): self.mock.recv_id = sentinel.tag network.Connection.disable_recv(self.mock) - gobject.source_remove.assert_called_once_with(sentinel.tag) + GObject.source_remove.assert_called_once_with(sentinel.tag) self.assertEqual(None, self.mock.recv_id) - @patch.object(gobject, 'source_remove', new=Mock()) + @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_recv_already_deregistered(self): self.mock.recv_id = None network.Connection.disable_recv(self.mock) - self.assertEqual(0, gobject.source_remove.call_count) + self.assertEqual(0, GObject.source_remove.call_count) self.assertEqual(None, self.mock.recv_id) def test_enable_recv_on_closed_socket(self): @@ -216,27 +216,27 @@ class ConnectionTest(unittest.TestCase): self.mock.stop.assert_called_once_with(any_unicode) self.assertEqual(None, self.mock.recv_id) - @patch.object(gobject, 'io_add_watch', new=Mock()) + @patch.object(GObject, 'io_add_watch', new=Mock()) def test_enable_send_registers_with_gobject(self): self.mock.send_id = None self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.fileno.return_value = sentinel.fileno - gobject.io_add_watch.return_value = sentinel.tag + GObject.io_add_watch.return_value = sentinel.tag network.Connection.enable_send(self.mock) - gobject.io_add_watch.assert_called_once_with( + GObject.io_add_watch.assert_called_once_with( sentinel.fileno, - gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, + GObject.IO_OUT | GObject.IO_ERR | GObject.IO_HUP, self.mock.send_callback) self.assertEqual(sentinel.tag, self.mock.send_id) - @patch.object(gobject, 'io_add_watch', new=Mock()) + @patch.object(GObject, 'io_add_watch', new=Mock()) def test_enable_send_already_registered(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.send_id = sentinel.tag network.Connection.enable_send(self.mock) - self.assertEqual(0, gobject.io_add_watch.call_count) + self.assertEqual(0, GObject.io_add_watch.call_count) def test_enable_send_does_not_change_tag(self): self.mock.send_id = sentinel.tag @@ -245,20 +245,20 @@ class ConnectionTest(unittest.TestCase): network.Connection.enable_send(self.mock) self.assertEqual(sentinel.tag, self.mock.send_id) - @patch.object(gobject, 'source_remove', new=Mock()) + @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_send_deregisters(self): self.mock.send_id = sentinel.tag network.Connection.disable_send(self.mock) - gobject.source_remove.assert_called_once_with(sentinel.tag) + GObject.source_remove.assert_called_once_with(sentinel.tag) self.assertEqual(None, self.mock.send_id) - @patch.object(gobject, 'source_remove', new=Mock()) + @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_send_already_deregistered(self): self.mock.send_id = None network.Connection.disable_send(self.mock) - self.assertEqual(0, gobject.source_remove.call_count) + self.assertEqual(0, GObject.source_remove.call_count) self.assertEqual(None, self.mock.send_id) def test_enable_send_on_closed_socket(self): @@ -269,36 +269,36 @@ class ConnectionTest(unittest.TestCase): network.Connection.enable_send(self.mock) self.assertEqual(None, self.mock.send_id) - @patch.object(gobject, 'timeout_add_seconds', new=Mock()) + @patch.object(GObject, 'timeout_add_seconds', new=Mock()) def test_enable_timeout_clears_existing_timeouts(self): self.mock.timeout = 10 network.Connection.enable_timeout(self.mock) self.mock.disable_timeout.assert_called_once_with() - @patch.object(gobject, 'timeout_add_seconds', new=Mock()) + @patch.object(GObject, 'timeout_add_seconds', new=Mock()) def test_enable_timeout_add_gobject_timeout(self): self.mock.timeout = 10 - gobject.timeout_add_seconds.return_value = sentinel.tag + GObject.timeout_add_seconds.return_value = sentinel.tag network.Connection.enable_timeout(self.mock) - gobject.timeout_add_seconds.assert_called_once_with( + GObject.timeout_add_seconds.assert_called_once_with( 10, self.mock.timeout_callback) self.assertEqual(sentinel.tag, self.mock.timeout_id) - @patch.object(gobject, 'timeout_add_seconds', new=Mock()) + @patch.object(GObject, 'timeout_add_seconds', new=Mock()) def test_enable_timeout_does_not_add_timeout(self): self.mock.timeout = 0 network.Connection.enable_timeout(self.mock) - self.assertEqual(0, gobject.timeout_add_seconds.call_count) + self.assertEqual(0, GObject.timeout_add_seconds.call_count) self.mock.timeout = -1 network.Connection.enable_timeout(self.mock) - self.assertEqual(0, gobject.timeout_add_seconds.call_count) + self.assertEqual(0, GObject.timeout_add_seconds.call_count) self.mock.timeout = None network.Connection.enable_timeout(self.mock) - self.assertEqual(0, gobject.timeout_add_seconds.call_count) + self.assertEqual(0, GObject.timeout_add_seconds.call_count) def test_enable_timeout_does_not_call_disable_for_invalid_timeout(self): self.mock.timeout = 0 @@ -313,20 +313,20 @@ class ConnectionTest(unittest.TestCase): network.Connection.enable_timeout(self.mock) self.assertEqual(0, self.mock.disable_timeout.call_count) - @patch.object(gobject, 'source_remove', new=Mock()) + @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_timeout_deregisters(self): self.mock.timeout_id = sentinel.tag network.Connection.disable_timeout(self.mock) - gobject.source_remove.assert_called_once_with(sentinel.tag) + GObject.source_remove.assert_called_once_with(sentinel.tag) self.assertEqual(None, self.mock.timeout_id) - @patch.object(gobject, 'source_remove', new=Mock()) + @patch.object(GObject, 'source_remove', new=Mock()) def test_disable_timeout_already_deregistered(self): self.mock.timeout_id = None network.Connection.disable_timeout(self.mock) - self.assertEqual(0, gobject.source_remove.call_count) + self.assertEqual(0, GObject.source_remove.call_count) self.assertEqual(None, self.mock.timeout_id) def test_queue_send_acquires_and_releases_lock(self): @@ -372,7 +372,7 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.assertTrue(network.Connection.recv_callback( - self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup(self): @@ -380,7 +380,7 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.assertTrue(network.Connection.recv_callback( - self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_HUP)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup_and_io_err(self): @@ -389,7 +389,7 @@ class ConnectionTest(unittest.TestCase): self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, - gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + GObject.IO_IN | GObject.IO_HUP | GObject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_sends_data_to_actor(self): @@ -398,7 +398,7 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.assertTrue(network.Connection.recv_callback( - self.mock, sentinel.fd, gobject.IO_IN)) + self.mock, sentinel.fd, GObject.IO_IN)) self.mock.actor_ref.tell.assert_called_once_with( {'received': 'data'}) @@ -409,7 +409,7 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError() self.assertTrue(network.Connection.recv_callback( - self.mock, sentinel.fd, gobject.IO_IN)) + self.mock, sentinel.fd, GObject.IO_IN)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_gets_no_data(self): @@ -418,7 +418,7 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.assertTrue(network.Connection.recv_callback( - self.mock, sentinel.fd, gobject.IO_IN)) + self.mock, sentinel.fd, GObject.IO_IN)) self.assertEqual(self.mock.mock_calls, [ call.sock.recv(any_int), call.disable_recv(), @@ -431,7 +431,7 @@ class ConnectionTest(unittest.TestCase): for error in (errno.EWOULDBLOCK, errno.EINTR): self.mock.sock.recv.side_effect = socket.error(error, '') self.assertTrue(network.Connection.recv_callback( - self.mock, sentinel.fd, gobject.IO_IN)) + self.mock, sentinel.fd, GObject.IO_IN)) self.assertEqual(0, self.mock.stop.call_count) def test_recv_callback_unrecoverable_error(self): @@ -439,7 +439,7 @@ class ConnectionTest(unittest.TestCase): self.mock.sock.recv.side_effect = socket.error self.assertTrue(network.Connection.recv_callback( - self.mock, sentinel.fd, gobject.IO_IN)) + self.mock, sentinel.fd, GObject.IO_IN)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_err(self): @@ -450,7 +450,7 @@ class ConnectionTest(unittest.TestCase): self.mock.send_buffer = '' self.assertTrue(network.Connection.send_callback( - self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup(self): @@ -461,7 +461,7 @@ class ConnectionTest(unittest.TestCase): self.mock.send_buffer = '' self.assertTrue(network.Connection.send_callback( - self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.mock, sentinel.fd, GObject.IO_IN | GObject.IO_HUP)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup_and_io_err(self): @@ -473,7 +473,7 @@ class ConnectionTest(unittest.TestCase): self.assertTrue(network.Connection.send_callback( self.mock, sentinel.fd, - gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + GObject.IO_IN | GObject.IO_HUP | GObject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_acquires_and_releases_lock(self): @@ -484,7 +484,7 @@ class ConnectionTest(unittest.TestCase): self.mock.sock.send.return_value = 0 self.assertTrue(network.Connection.send_callback( - self.mock, sentinel.fd, gobject.IO_IN)) + self.mock, sentinel.fd, GObject.IO_IN)) self.mock.send_lock.acquire.assert_called_once_with(False) self.mock.send_lock.release.assert_called_once_with() @@ -496,7 +496,7 @@ class ConnectionTest(unittest.TestCase): self.mock.sock.send.return_value = 0 self.assertTrue(network.Connection.send_callback( - self.mock, sentinel.fd, gobject.IO_IN)) + self.mock, sentinel.fd, GObject.IO_IN)) self.mock.send_lock.acquire.assert_called_once_with(False) self.assertEqual(0, self.mock.sock.send.call_count) @@ -507,7 +507,7 @@ class ConnectionTest(unittest.TestCase): self.mock.send.return_value = '' self.assertTrue(network.Connection.send_callback( - self.mock, sentinel.fd, gobject.IO_IN)) + self.mock, sentinel.fd, GObject.IO_IN)) self.mock.disable_send.assert_called_once_with() self.mock.send.assert_called_once_with('data') self.assertEqual('', self.mock.send_buffer) @@ -519,7 +519,7 @@ class ConnectionTest(unittest.TestCase): self.mock.send.return_value = 'ta' self.assertTrue(network.Connection.send_callback( - self.mock, sentinel.fd, gobject.IO_IN)) + self.mock, sentinel.fd, GObject.IO_IN)) self.mock.send.assert_called_once_with('data') self.assertEqual('ta', self.mock.send_buffer) diff --git a/tests/internal/network/test_server.py b/tests/internal/network/test_server.py index af8effd2..1df25dbc 100644 --- a/tests/internal/network/test_server.py +++ b/tests/internal/network/test_server.py @@ -4,7 +4,7 @@ import errno import socket import unittest -import gobject +from gi.repository import GObject from mock import Mock, patch, sentinel @@ -91,11 +91,11 @@ class ServerTest(unittest.TestCase): network.Server.create_server_socket( self.mock, sentinel.host, sentinel.port) - @patch.object(gobject, 'io_add_watch', new=Mock()) + @patch.object(GObject, 'io_add_watch', new=Mock()) def test_register_server_socket_sets_up_io_watch(self): network.Server.register_server_socket(self.mock, sentinel.fileno) - gobject.io_add_watch.assert_called_once_with( - sentinel.fileno, gobject.IO_IN, self.mock.handle_connection) + GObject.io_add_watch.assert_called_once_with( + sentinel.fileno, GObject.IO_IN, self.mock.handle_connection) def test_handle_connection(self): self.mock.accept_connection.return_value = ( @@ -103,7 +103,7 @@ class ServerTest(unittest.TestCase): self.mock.maximum_connections_exceeded.return_value = False self.assertTrue(network.Server.handle_connection( - self.mock, sentinel.fileno, gobject.IO_IN)) + self.mock, sentinel.fileno, GObject.IO_IN)) self.mock.accept_connection.assert_called_once_with() self.mock.maximum_connections_exceeded.assert_called_once_with() self.mock.init_connection.assert_called_once_with( @@ -116,7 +116,7 @@ class ServerTest(unittest.TestCase): self.mock.maximum_connections_exceeded.return_value = True self.assertTrue(network.Server.handle_connection( - self.mock, sentinel.fileno, gobject.IO_IN)) + self.mock, sentinel.fileno, GObject.IO_IN)) self.mock.accept_connection.assert_called_once_with() self.mock.maximum_connections_exceeded.assert_called_once_with() self.mock.reject_connection.assert_called_once_with( diff --git a/tests/internal/test_deps.py b/tests/internal/test_deps.py index 27e6f629..84c79d9c 100644 --- a/tests/internal/test_deps.py +++ b/tests/internal/test_deps.py @@ -8,11 +8,8 @@ import mock import pkg_resources -import pygst -pygst.require('0.10') -import gst # noqa - from mopidy.internal import deps +from mopidy.internal.gi import Gst, gi class DepsTest(unittest.TestCase): @@ -74,12 +71,11 @@ class DepsTest(unittest.TestCase): self.assertEqual('GStreamer', result['name']) self.assertEqual( - '.'.join(map(str, gst.get_gst_version())), result['version']) - self.assertIn('gst', result['path']) + '.'.join(map(str, Gst.version())), result['version']) + self.assertIn('gi', result['path']) self.assertNotIn('__init__.py', result['path']) - self.assertIn('Python wrapper: gst-python', result['other']) - self.assertIn( - '.'.join(map(str, gst.get_pygst_version())), result['other']) + self.assertIn('Python wrapper: python-gi', result['other']) + self.assertIn(gi.__version__, result['other']) self.assertIn('Relevant elements:', result['other']) @mock.patch('pkg_resources.get_distribution') diff --git a/tests/internal/test_path.py b/tests/internal/test_path.py index 8aa8f7c1..751e7c6e 100644 --- a/tests/internal/test_path.py +++ b/tests/internal/test_path.py @@ -7,7 +7,7 @@ import shutil import tempfile import unittest -import glib +from gi.repository import GLib from mopidy import compat, exceptions from mopidy.internal import path @@ -215,7 +215,7 @@ class ExpandPathTest(unittest.TestCase): def test_xdg_subsititution(self): self.assertEqual( - glib.get_user_data_dir() + b'/foo', + GLib.get_user_data_dir() + b'/foo', path.expand_path(b'$XDG_DATA_DIR/foo')) def test_xdg_subsititution_unknown(self): diff --git a/tests/local/test_translator.py b/tests/local/test_translator.py index e28de173..7839cd58 100644 --- a/tests/local/test_translator.py +++ b/tests/local/test_translator.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals import pytest +from mopidy import compat from mopidy.local import translator @@ -89,7 +90,9 @@ def test_path_to_file_uri(path, uri): (b'\x00\x01\x02', 'local:track:%00%01%02'), ]) def test_path_to_local_track_uri(path, uri): - assert translator.path_to_local_track_uri(path) == uri + result = translator.path_to_local_track_uri(path) + assert isinstance(result, compat.text_type) + assert result == uri @pytest.mark.parametrize('path,uri', [ @@ -99,4 +102,6 @@ def test_path_to_local_track_uri(path, uri): (b'\x00\x01\x02', 'local:directory:%00%01%02'), ]) def test_path_to_local_directory_uri(path, uri): - assert translator.path_to_local_directory_uri(path) == uri + result = translator.path_to_local_directory_uri(path) + assert isinstance(result, compat.text_type) + assert result == uri diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 51112057..9f13fc22 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()) @@ -455,9 +420,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)