Merge branch 'develop' of https://github.com/mopidy/mopidy into fix/310-persist-mopidy-state-between-runs

Conflicts:
	mopidy/audio/scan.py
Fix conflicts.
This commit is contained in:
Jens Luetjen 2016-02-03 22:01:51 +01:00
commit 0a1e43c876
57 changed files with 1433 additions and 1291 deletions

View File

@ -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"

View File

@ -76,3 +76,4 @@
- Jelle van der Waa <jelle@vdwaa.nl>
- Alex Malone <jalexmalone@gmail.com>
- Daniel Hahler <git@thequod.de>
- Bryan Bennett <bbenne10@gmail.com>

View File

@ -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

View File

@ -4,7 +4,7 @@
Authors
*******
Mopidy is copyright 2009-2015 Stein Magnus Jodal and contributors. Mopidy is
Mopidy is copyright 2009-2016 Stein Magnus Jodal and contributors. Mopidy is
licensed under the `Apache License, Version 2.0
<http://www.apache.org/licenses/LICENSE-2.0>`_.

View File

@ -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)
===================

View File

@ -167,5 +167,5 @@ projects are a real match made in heaven."
Partify
-------
`Partify <https://github.com/fhats/partify>`_ is a web based MPD client focussing on
making music playing collaborative and social.
`Partify <https://github.com/fhats/partify>`_ is a web based MPD client
focussing on making music playing collaborative and social.

View File

@ -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']

View File

@ -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

View File

@ -1,129 +0,0 @@
.. _debian:
***************
Debian packages
***************
The Mopidy Debian package, ``mopidy``, is available from `apt.mopidy.com
<http://apt.mopidy.com/>`__ as well as from Debian, Ubuntu and other
Debian-based Linux distributions.
Some extensions are also available from all of these sources, while others,
like Mopidy-Spotify and its dependencies, are only available from
apt.mopidy.com. This may either be temporary until the package is uploaded to
Debian and with time propagates to the other distributions. It may also be more
long term, like in the Mopidy-Spotify case where there is uncertainities around
licensing and distribution of non-free packages.
Installation
============
See :ref:`debian-install`.
Running as a system service
===========================
The Debian package comes with an init script. It starts Mopidy as a system
service running as the ``mopidy`` user, which is created by the package.
The Debian package version 0.18.3-1 and older starts Mopidy as a system
service by default. Version 0.18.3-2 and newer asks if you want to run Mopidy
as a system service, defaulting to not doing so.
If you're running 0.18.3-2 or newer, and you've changed your mind about whether
or not to run Mopidy as a system service, just run the following command to
reconfigure the package::
sudo dpkg-reconfigure mopidy
If you're running 0.18.3-1 or older, and don't want to use the init script to
run Mopidy as a system service, but instead just run Mopidy manually using your
own user, you need to disable the init script and stop Mopidy by running::
sudo update-rc.d mopidy disable
sudo service mopidy stop
This way of disabling the system service is compatible with the improved
0.18.3-2 or newer version of the Debian package, so if you later upgrade to a
newer version, you can change your mind using the ``dpkg-reconfigure`` command
above.
Differences when running as a system service
============================================
If you want to run Mopidy using the init script, there's a few differences
from a regular Mopidy setup you'll want to know about.
- All configuration is in :file:`/etc/mopidy`, not in your user's home
directory. The main configuration file is :file:`/etc/mopidy/mopidy.conf`.
You can do all your changes in this file.
- Mopidy extensions installed from Debian packages will sometimes install
additional configuration files in :file:`/usr/share/mopidy/conf.d/`. These
files just provide different defaults for the extension when run as a system
service. You can override anything from :file:`/usr/share/mopidy/conf.d/` in
the :file:`/etc/mopidy/mopidy.conf` configuration file.
Previously, the extension's default config was installed in
:file:`/etc/mopidy/extensions.d/`. This was removed with the Debian
package mopidy 0.19.4-3. If you have modified any files in
:file:`/etc/mopidy/extensions.d/`, you should redo your modifications in
:file:`/etc/mopidy/mopidy.conf` and delete the
:file:`/etc/mopidy/extensions.d/` directory.
- The init script runs Mopidy as the ``mopidy`` user. The ``mopidy`` user will
need read access to any local music you want Mopidy to play.
- To run Mopidy subcommands with the same user and config files as the init
script uses, you can use ``sudo mopidyctl <subcommand>``. In other words,
where you'll usually run::
mopidy config
You should instead run the following to inspect the system service's
configuration::
sudo mopidyctl config
The same applies to scanning your local music collection. Where you'll
normally run::
mopidy local scan
You should instead run::
sudo mopidyctl local scan
Previously, you used ``sudo service mopidy run <subcommand>`` instead of
``mopidyctl``. This was deprecated in Debian package version 0.19.4-3 in
favor of ``mopidyctl``, which also work for systems using systemd instead of
sysvinit and traditional init scripts.
- Mopidy is started, stopped, and restarted just like any other system
service::
sudo service mopidy start
sudo service mopidy stop
sudo service mopidy restart
- You can check if Mopidy is currently running as a system service by running::
sudo service mopidy status
- Mopidy installed from a Debian package can use Mopidy extensions installed
both from Debian packages and with pip. This has always been the case.
Mopidy installed with pip can use extensions installed with pip, but
not extensions installed from a Debian package released before August 2015.
This is because the Debian packages used to install extensions into
:file:`/usr/share/mopidy` which is normally not on your ``PYTHONPATH``.
Thus, your pip-installed Mopidy would not find the Debian package-installed
extensions.
In August 2015, all Mopidy extension Debian packages was modified to install
into :file:`/usr/lib/python2.7/dist-packages`, like any other Python Debian
package. Thus, Mopidy installed with pip can now use extensions installed
from Debian.

View File

@ -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

BIN
docs/ext/spotmop.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@ -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
==================

View File

@ -81,8 +81,8 @@ announcements related to Mopidy and Mopidy extensions.
installation/index
config
running
service
troubleshooting
debian
.. _ext:

View File

@ -16,7 +16,8 @@ If you are running Arch Linux, you can install Mopidy using the
pacman -Syu
#. Finally, you need to set a couple of :doc:`config values </config>`, and
then you're ready to :doc:`run Mopidy </running>`.
then you're ready to :doc:`run Mopidy </running>` or run Mopidy as a
:ref:`service <service>`.
Installing extensions

View File

@ -48,12 +48,9 @@ and armhf (compatible with Raspberry Pi 1 and 2).
sudo apt-get update
sudo apt-get install mopidy
#. Before continuing, make sure you've read the :ref:`debian` section to learn
about the differences between running Mopidy as a system service and
manually as your own system user.
#. Finally, you need to set a couple of :doc:`config values </config>`, and then
you're ready to :doc:`run Mopidy </running>`.
you're ready to :doc:`run Mopidy </running>` or run Mopidy as a
:ref:`service <service>`.
When a new release of Mopidy is out, and you can't wait for you system to
figure it out for itself, run the following to upgrade right away::
@ -87,44 +84,3 @@ about any other requirements needed for the extension to work properly.
For a full list of available Mopidy extensions, including those not
installable from apt.mopidy.com, see :ref:`ext`.
Missing extensions
==================
If you've installed a Mopidy extension with pip, restarted Mopidy, and Mopidy
doesn't find the extension, there's probably a simple explanation and solution.
Mopidy installed with APT can detect and use Mopidy extensions installed with
both APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`.
Mopidy installed with pip can only detect Mopidy extensions installed with pip.
pip usually installs Mopidy as :file:`/usr/local/bin/mopidy`.
If you have Mopidy installed from both APT and pip, then the pip-installed
Mopidy will probably shadow the APT-installed Mopidy because
:file:`/usr/local/bin` usually has precedence over :file:`/usr/bin` in the
``PATH`` environment variable. To check if this is the case on your system, you
can use ``which`` to see what installation of Mopidy you use when you run
``mopidy`` in your shell::
$ which mopidy
/usr/local/bin/mopidy
If this is the case on your system, the recommended solution is to check that
you have Mopidy installed from APT too::
$ /usr/bin/mopidy --version
Mopidy 0.19.5
And then uninstall the pip-installed Mopidy::
sudo pip uninstall mopidy
Depending on what shell you use, the shell may still try to use
:file:`/usr/local/bin/mopidy` even if it no longer exists. Check again with
``which mopidy`` what your shell believes is the right ``mopidy`` executable to
run. If the shell is still confused, you may need to restart it, or in the case
of zsh, run ``rehash`` to update the shell.
For more details on why this works this way, see :ref:`debian`.

View File

@ -86,6 +86,8 @@ For a full list of available Mopidy extensions, including those not installable
from Homebrew, see :ref:`ext`.
.. _osx-service:
Running Mopidy automatically on login
=====================================

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@ -1,75 +1,68 @@
.. _raspberrypi-installation:
*************************************
Raspberry Pi: Mopidy on a credit card
*************************************
************
Raspberry Pi
************
Mopidy runs nicely on a `Raspberry Pi <https://www.raspberrypi.org/>`_. As of
January 2013, Mopidy will run with Spotify support on both the armel
(soft-float) and armhf (hard-float) architectures, which includes the Raspbian
distribution.
Mopidy runs on all versions of `Raspberry Pi <https://www.raspberrypi.org/>`_.
However, note that Raspberry Pi 2 B's CPU is approximately six times as
powerful as Raspberry Pi 1 and Raspberry Pi Zero, so Mopidy will be more joyful
to use on a Raspberry Pi 2.
.. image:: raspberry-pi-by-jwrodgers.jpg
.. image:: raspberrypi2.jpg
:width: 640
:height: 427
:height: 363
.. _raspi-wheezy:
How to for Raspbian "wheezy" and Debian "wheezy"
================================================
How to for Raspbian Jessie
==========================
This guide applies for both:
#. Download the latest Jessie or Jessie Lite disk image from
http://www.raspberrypi.org/downloads/raspbian/.
- Raspbian "wheezy" for armhf (hard-float), and
- Debian "wheezy" for armel (soft-float)
If you're only using your Pi for Mopidy, go with Jessie Lite as you won't
need the full graphical desktop included in the Jessie image.
If you don't know which one to select, go for the armhf variant, as it'll give
you a lot better performance.
#. Flash the Raspbian image you downloaded to your SD card.
#. Download the latest "wheezy" disk image from
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
<https://www.raspberrypi.org/documentation/installation/installing-images/README.md>`_
for instructions.
#. Flash the OS image to your SD card. See
http://elinux.org/RPi_Easy_SD_Card_Setup for help.
#. If you connect a monitor and a keyboard, you'll see that the Pi boots right
into the ``raspi-config`` tool.
#. If you have an SD card that's >2 GB, you don't have to resize the file
systems on another computer. Just boot up your Raspberry Pi with the
unaltered partions, and it will boot right into the ``raspi-config`` tool,
which will let you grow the root file system to fill the SD card. This tool
will also allow you do other useful stuff, like turning on the SSH server.
If you boot with only a network cable connected, you'll have to find the IP
address of the Pi yourself, e.g. by looking in the client list on your
router/DHCP server. When you have found the Pi's IP address, you can SSH to
the IP address and login with the user ``pi`` and password ``raspberry``.
Once logged in, run ``sudo raspi-config`` to start the config tool as the
``root`` user.
#. You can login to the default user using username ``pi`` and password
``raspberry``. To become root, just enter ``sudo -i``.
#. Use the ``raspi-config`` tool to setup the basics of your Pi. You might want
to do one or more of the following:
#. To avoid a couple of potential problems with Mopidy, turn on IPv6 support:
- Expand the file system to fill the SD card.
- Change the password of the ``pi`` user.
- Change the time zone.
- Load the IPv6 kernel module now::
Under "Advanced Options":
sudo modprobe ipv6
- Set a hostname.
- Enable SSH if not already enabled.
- If your will use HDMI for display and 3.5mm jack for audio, force the
audio output to the 3.5mm jack. By default it will use HDMI for audio
output if an HDMI cable is connected and the 3.5mm jack if not.
- Add ``ipv6`` to ``/etc/modules`` to ensure the IPv6 kernel module is
loaded on boot::
Once done, select "Finish" and restart your Pi.
echo ipv6 | sudo tee -a /etc/modules
If you want to change any settings later, you can simply rerun ``sudo
raspi-config``.
#. Since I have a HDMI cable connected, but want the sound on the analog sound
connector, I have to run::
sudo amixer cset numid=3 1
to force it to use analog output. ``1`` means analog, ``0`` means auto, and
is the default, while ``2`` means HDMI. You can test sound output
independent of Mopidy by running::
aplay /usr/share/sounds/alsa/Front_Center.wav
If you hear a voice saying "Front Center", then your sound is working.
To make the change to analog output stick, you can add the ``amixer``
command to e.g. ``/etc/rc.local``, which will be executed when the system is
booting.
#. Once you've rebooted and has logged in as the ``pi`` user, you can enter
``sudo -i`` to become ``root``.
#. Install Mopidy and its dependencies as described in :ref:`debian-install`.
@ -79,114 +72,19 @@ you a lot better performance.
starting at boot.
Appendix A: Fixing audio quality issues
=======================================
Testing sound output
====================
As of about April 2013 the following steps should resolve any audio
issues for HDMI and analog without the use of an external USB sound
card.
You can test sound output independent of Mopidy by running::
#. Ensure your system is up to date. On Debian based systems run::
aplay /usr/share/sounds/alsa/Front_Center.wav
sudo apt-get update
sudo apt-get dist-upgrade
If you hear a voice saying "Front Center", then your sound is working.
#. Ensure you have a new enough firmware. On Debian based systems
`rpi-update <https://github.com/Hexxeh/rpi-update>`_
can be used.
If you want to change your audio output setting, simply rerun ``sudo
raspi-config``. Alternatively, you can change the audio output setting
directly by running:
#. Update either ``~/.asoundrc`` or ``/etc/asound.conf`` to the
following::
pcm.!default {
type hw
card 0
}
ctl.!default {
type hw
card 0
}
Note that if you have an ``~/.asoundrc`` it will overide any global
settings from ``/etc/asound.conf``.
#. For Mopidy to output audio directly to ALSA, instead of Jack which
GStreamer usually defaults to on Raspberry Pi, install the
``gstreamer0.10-alsa`` package::
sudo apt-get install gstreamer0.10-alsa
Then update your ``~/.config/mopidy/mopidy.conf`` to contain::
[audio]
output = alsasink
Following these steps you should be able to get crackle free sound on either
HDMI or analog. Note that you might need to ensure that PulseAudio is no longer
running to get this working nicely.
This recipe has been confirmed as working by a number of users on our issue
tracker and IRC. As a reference, the following versions where used for testing
this, however all newer and some older version are likely to work as we have
not determined the exact revision that fixed this::
$ uname -a
Linux raspberrypi 3.6.11+ #408 PREEMPT Wed Apr 10 20:33:39 BST 2013 armv6l GNU/Linux
$ /opt/vc/bin/vcgencmd version
Apr 25 2013 01:07:36
Copyright (c) 2012 Broadcom
version 386589 (release)
The only remaining known issue is a slight gap in playback at track changes
this is likely due to gapless playback not being implemented and is being
worked on irrespective of Raspberry Pi related work.
Appendix B: Raspbmc not booting
===============================
Due to a dependency version problem where XBMC uses another version of libtag
than what Debian originally ships with, you might have to make some minor
changes for Raspbmc to start properly after installing Mopidy.
If you notice that XBMC is not starting but gets stuck in a loop,
you need to make the following changes::
sudo ln -sf /home/pi/.xbmc-current/xbmc-bin/lib/xbmc/system/libtag.so.1 \
/usr/lib/arm-linux-gnueabihf/libtag.so.1
However, this will not persist the changes. To persist the changes edit
:file:`/etc/ld.so.conf.d/arm-linux-gnueabihf.conf` and add the following at the
top::
/home/pi/.xbmc-current/xbmc-bin/lib/xbmc/system
It's very important to add it at the top of the file as this indicates the
priority of the folder in which to look for shared libraries.
XBMC doesn't play nicely with the system wide installed version of libtag that
got installed together with Mopidy, but rather vendors in its own version.
More info about this issue can be found in `this post
<http://geeks.noeit.com/xbmc-library-dependency-error/>`_.
Please note that if you're running Xbian or another XBMC distribution these
instructions might vary for your system.
Appendix C: Installation on XBian
=================================
Similar to the Raspbmc issue outlined in Appendix B, it's not possible to
install Mopidy on XBian without first resolving a dependency problem between
``gstreamer0.10-plugins-good`` and ``libtag1c2a``. More information can be
found in `this issue
<https://github.com/xbianonpi/xbian/issues/378#issuecomment-37723392>`_.
Run the following commands to remedy this and then install Mopidy as normal::
cd /tmp
wget http://apt.xbian.org/pool/stable/rpi-wheezy/l/libtag1c2a/libtag1c2a_1.7.2-1_armhf.deb
sudo dpkg -i libtag1c2a_1.7.2-1_armhf.deb
rm libtag1c2a_1.7.2-1_armhf.deb
- Auto (HDMI if connected, else 3.5mm jack): ``sudo amixer cset numid=3 0``
- Use 3.5mm jack: ``sudo amixer cset numid=3 1``
- Use HDMI: ``sudo amixer cset numid=3 2``

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@ -37,36 +37,40 @@ please follow the directions :ref:`here <contributing>`.
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 <contributing>`.
<https://pypi.python.org/pypi/Mopidy>`_. 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 </config>`, and
then you're ready to :doc:`run Mopidy </running>`.

View File

@ -39,17 +39,8 @@ using ``pkill``::
pkill mopidy
Init scripts
============
Running as a service
====================
- The ``mopidy`` package at `apt.mopidy.com <http://apt.mopidy.com/>`__ comes
with an `sysvinit init script
<https://github.com/mopidy/mopidy/blob/debian/debian/mopidy.init>`_. For
more details, see the :ref:`debian` section of the docs.
- The ``mopidy`` package in `Arch Linux
<https://www.archlinux.org/packages/community/any/mopidy/>`__ comes with a systemd init
script.
- 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`.

137
docs/service.rst Normal file
View File

@ -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 <subcommand>``. In other words, where you'll
usually run::
mopidy config
You should instead run the following to inspect the service's configuration::
sudo mopidyctl config
The same applies to scanning your local music collection. Where you'll normally
run::
mopidy local scan
You should instead run::
sudo mopidyctl local scan
Service management with systemd
===============================
On modern systems using systemd you can enable the Mopidy service by running::
sudo systemctl enable mopidy
This will make Mopidy start when the system boots.
Mopidy is started, stopped, and restarted just like any other systemd service::
sudo systemctl start mopidy
sudo systemctl stop mopidy
sudo systemctl restart mopidy
You can check if Mopidy is currently running as a service by running::
sudo systemctl status mopidy
Service management on Debian
============================
On Debian systems (both those using systemd and not) you can enable the Mopidy
service by running::
sudo dpkg-reconfigure mopidy
Mopidy can be started, stopped, and restarted using the ``service`` command::
sudo service mopidy start
sudo service mopidy stop
sudo service mopidy restart
You can check if Mopidy is currently running as a service by running::
sudo service mopidy status
Service on OS X
===============
If you're installing Mopidy on OS X, see :ref:`osx-service`.
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
<http://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/WhatIsWrongWithSystemWide/>`_
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

View File

@ -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'

View File

@ -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,

View File

@ -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):
"""

View File

@ -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)

View File

@ -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)

139
mopidy/audio/tags.py Normal file
View File

@ -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]]

View File

@ -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)))

View File

@ -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']

View File

@ -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]

View File

@ -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

View File

@ -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)

View File

@ -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]

42
mopidy/internal/gi.py Normal file
View File

@ -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',
]

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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')

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

333
tests/audio/test_tags.py Normal file
View File

@ -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]))

View File

@ -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)

View File

@ -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')

View File

@ -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()

View File

@ -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)

View File

@ -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(

View File

@ -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')

View File

@ -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):

View File

@ -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

View File

@ -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')

View File

@ -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)