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:
commit
0a1e43c876
@ -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"
|
||||
|
||||
1
AUTHORS
1
AUTHORS
@ -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>
|
||||
|
||||
@ -55,20 +55,28 @@ Data model API
|
||||
:synopsis: Data model API
|
||||
|
||||
.. autoclass:: mopidy.models.Ref
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.Track
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.Album
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.Artist
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.Playlist
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.Image
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.TlTrack
|
||||
:members:
|
||||
|
||||
.. autoclass:: mopidy.models.SearchResult
|
||||
:members:
|
||||
|
||||
|
||||
Data model helpers
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
Authors
|
||||
*******
|
||||
|
||||
Mopidy is copyright 2009-2015 Stein Magnus Jodal and contributors. Mopidy is
|
||||
Mopidy is copyright 2009-2016 Stein Magnus Jodal and contributors. Mopidy is
|
||||
licensed under the `Apache License, Version 2.0
|
||||
<http://www.apache.org/licenses/LICENSE-2.0>`_.
|
||||
|
||||
|
||||
@ -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)
|
||||
===================
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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']
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
129
docs/debian.rst
129
docs/debian.rst
@ -1,129 +0,0 @@
|
||||
.. _debian:
|
||||
|
||||
***************
|
||||
Debian packages
|
||||
***************
|
||||
|
||||
The Mopidy Debian package, ``mopidy``, is available from `apt.mopidy.com
|
||||
<http://apt.mopidy.com/>`__ as well as from Debian, Ubuntu and other
|
||||
Debian-based Linux distributions.
|
||||
|
||||
Some extensions are also available from all of these sources, while others,
|
||||
like Mopidy-Spotify and its dependencies, are only available from
|
||||
apt.mopidy.com. This may either be temporary until the package is uploaded to
|
||||
Debian and with time propagates to the other distributions. It may also be more
|
||||
long term, like in the Mopidy-Spotify case where there is uncertainities around
|
||||
licensing and distribution of non-free packages.
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
See :ref:`debian-install`.
|
||||
|
||||
|
||||
Running as a system service
|
||||
===========================
|
||||
|
||||
The Debian package comes with an init script. It starts Mopidy as a system
|
||||
service running as the ``mopidy`` user, which is created by the package.
|
||||
|
||||
The Debian package version 0.18.3-1 and older starts Mopidy as a system
|
||||
service by default. Version 0.18.3-2 and newer asks if you want to run Mopidy
|
||||
as a system service, defaulting to not doing so.
|
||||
|
||||
If you're running 0.18.3-2 or newer, and you've changed your mind about whether
|
||||
or not to run Mopidy as a system service, just run the following command to
|
||||
reconfigure the package::
|
||||
|
||||
sudo dpkg-reconfigure mopidy
|
||||
|
||||
If you're running 0.18.3-1 or older, and don't want to use the init script to
|
||||
run Mopidy as a system service, but instead just run Mopidy manually using your
|
||||
own user, you need to disable the init script and stop Mopidy by running::
|
||||
|
||||
sudo update-rc.d mopidy disable
|
||||
sudo service mopidy stop
|
||||
|
||||
This way of disabling the system service is compatible with the improved
|
||||
0.18.3-2 or newer version of the Debian package, so if you later upgrade to a
|
||||
newer version, you can change your mind using the ``dpkg-reconfigure`` command
|
||||
above.
|
||||
|
||||
|
||||
Differences when running as a system service
|
||||
============================================
|
||||
|
||||
If you want to run Mopidy using the init script, there's a few differences
|
||||
from a regular Mopidy setup you'll want to know about.
|
||||
|
||||
- All configuration is in :file:`/etc/mopidy`, not in your user's home
|
||||
directory. The main configuration file is :file:`/etc/mopidy/mopidy.conf`.
|
||||
You can do all your changes in this file.
|
||||
|
||||
- Mopidy extensions installed from Debian packages will sometimes install
|
||||
additional configuration files in :file:`/usr/share/mopidy/conf.d/`. These
|
||||
files just provide different defaults for the extension when run as a system
|
||||
service. You can override anything from :file:`/usr/share/mopidy/conf.d/` in
|
||||
the :file:`/etc/mopidy/mopidy.conf` configuration file.
|
||||
|
||||
Previously, the extension's default config was installed in
|
||||
:file:`/etc/mopidy/extensions.d/`. This was removed with the Debian
|
||||
package mopidy 0.19.4-3. If you have modified any files in
|
||||
:file:`/etc/mopidy/extensions.d/`, you should redo your modifications in
|
||||
:file:`/etc/mopidy/mopidy.conf` and delete the
|
||||
:file:`/etc/mopidy/extensions.d/` directory.
|
||||
|
||||
- The init script runs Mopidy as the ``mopidy`` user. The ``mopidy`` user will
|
||||
need read access to any local music you want Mopidy to play.
|
||||
|
||||
- To run Mopidy subcommands with the same user and config files as the init
|
||||
script uses, you can use ``sudo mopidyctl <subcommand>``. In other words,
|
||||
where you'll usually run::
|
||||
|
||||
mopidy config
|
||||
|
||||
You should instead run the following to inspect the system service's
|
||||
configuration::
|
||||
|
||||
sudo mopidyctl config
|
||||
|
||||
The same applies to scanning your local music collection. Where you'll
|
||||
normally run::
|
||||
|
||||
mopidy local scan
|
||||
|
||||
You should instead run::
|
||||
|
||||
sudo mopidyctl local scan
|
||||
|
||||
Previously, you used ``sudo service mopidy run <subcommand>`` instead of
|
||||
``mopidyctl``. This was deprecated in Debian package version 0.19.4-3 in
|
||||
favor of ``mopidyctl``, which also work for systems using systemd instead of
|
||||
sysvinit and traditional init scripts.
|
||||
|
||||
- Mopidy is started, stopped, and restarted just like any other system
|
||||
service::
|
||||
|
||||
sudo service mopidy start
|
||||
sudo service mopidy stop
|
||||
sudo service mopidy restart
|
||||
|
||||
- You can check if Mopidy is currently running as a system service by running::
|
||||
|
||||
sudo service mopidy status
|
||||
|
||||
- Mopidy installed from a Debian package can use Mopidy extensions installed
|
||||
both from Debian packages and with pip. This has always been the case.
|
||||
|
||||
Mopidy installed with pip can use extensions installed with pip, but
|
||||
not extensions installed from a Debian package released before August 2015.
|
||||
This is because the Debian packages used to install extensions into
|
||||
:file:`/usr/share/mopidy` which is normally not on your ``PYTHONPATH``.
|
||||
Thus, your pip-installed Mopidy would not find the Debian package-installed
|
||||
extensions.
|
||||
|
||||
In August 2015, all Mopidy extension Debian packages was modified to install
|
||||
into :file:`/usr/lib/python2.7/dist-packages`, like any other Python Debian
|
||||
package. Thus, Mopidy installed with pip can now use extensions installed
|
||||
from Debian.
|
||||
@ -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
BIN
docs/ext/spotmop.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@ -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
|
||||
==================
|
||||
|
||||
|
||||
@ -81,8 +81,8 @@ announcements related to Mopidy and Mopidy extensions.
|
||||
installation/index
|
||||
config
|
||||
running
|
||||
service
|
||||
troubleshooting
|
||||
debian
|
||||
|
||||
|
||||
.. _ext:
|
||||
|
||||
@ -16,7 +16,8 @@ If you are running Arch Linux, you can install Mopidy using the
|
||||
pacman -Syu
|
||||
|
||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||
then you're ready to :doc:`run Mopidy </running>`.
|
||||
then you're ready to :doc:`run Mopidy </running>` or run Mopidy as a
|
||||
:ref:`service <service>`.
|
||||
|
||||
|
||||
Installing extensions
|
||||
|
||||
@ -48,12 +48,9 @@ and armhf (compatible with Raspberry Pi 1 and 2).
|
||||
sudo apt-get update
|
||||
sudo apt-get install mopidy
|
||||
|
||||
#. Before continuing, make sure you've read the :ref:`debian` section to learn
|
||||
about the differences between running Mopidy as a system service and
|
||||
manually as your own system user.
|
||||
|
||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and then
|
||||
you're ready to :doc:`run Mopidy </running>`.
|
||||
you're ready to :doc:`run Mopidy </running>` or run Mopidy as a
|
||||
:ref:`service <service>`.
|
||||
|
||||
When a new release of Mopidy is out, and you can't wait for you system to
|
||||
figure it out for itself, run the following to upgrade right away::
|
||||
@ -87,44 +84,3 @@ about any other requirements needed for the extension to work properly.
|
||||
|
||||
For a full list of available Mopidy extensions, including those not
|
||||
installable from apt.mopidy.com, see :ref:`ext`.
|
||||
|
||||
|
||||
Missing extensions
|
||||
==================
|
||||
|
||||
If you've installed a Mopidy extension with pip, restarted Mopidy, and Mopidy
|
||||
doesn't find the extension, there's probably a simple explanation and solution.
|
||||
|
||||
Mopidy installed with APT can detect and use Mopidy extensions installed with
|
||||
both APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`.
|
||||
|
||||
Mopidy installed with pip can only detect Mopidy extensions installed with pip.
|
||||
pip usually installs Mopidy as :file:`/usr/local/bin/mopidy`.
|
||||
|
||||
If you have Mopidy installed from both APT and pip, then the pip-installed
|
||||
Mopidy will probably shadow the APT-installed Mopidy because
|
||||
:file:`/usr/local/bin` usually has precedence over :file:`/usr/bin` in the
|
||||
``PATH`` environment variable. To check if this is the case on your system, you
|
||||
can use ``which`` to see what installation of Mopidy you use when you run
|
||||
``mopidy`` in your shell::
|
||||
|
||||
$ which mopidy
|
||||
/usr/local/bin/mopidy
|
||||
|
||||
If this is the case on your system, the recommended solution is to check that
|
||||
you have Mopidy installed from APT too::
|
||||
|
||||
$ /usr/bin/mopidy --version
|
||||
Mopidy 0.19.5
|
||||
|
||||
And then uninstall the pip-installed Mopidy::
|
||||
|
||||
sudo pip uninstall mopidy
|
||||
|
||||
Depending on what shell you use, the shell may still try to use
|
||||
:file:`/usr/local/bin/mopidy` even if it no longer exists. Check again with
|
||||
``which mopidy`` what your shell believes is the right ``mopidy`` executable to
|
||||
run. If the shell is still confused, you may need to restart it, or in the case
|
||||
of zsh, run ``rehash`` to update the shell.
|
||||
|
||||
For more details on why this works this way, see :ref:`debian`.
|
||||
|
||||
@ -86,6 +86,8 @@ For a full list of available Mopidy extensions, including those not installable
|
||||
from Homebrew, see :ref:`ext`.
|
||||
|
||||
|
||||
.. _osx-service:
|
||||
|
||||
Running Mopidy automatically on login
|
||||
=====================================
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@ -1,75 +1,68 @@
|
||||
.. _raspberrypi-installation:
|
||||
|
||||
*************************************
|
||||
Raspberry Pi: Mopidy on a credit card
|
||||
*************************************
|
||||
************
|
||||
Raspberry Pi
|
||||
************
|
||||
|
||||
Mopidy runs nicely on a `Raspberry Pi <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``
|
||||
|
||||
BIN
docs/installation/raspberrypi2.jpg
Normal file
BIN
docs/installation/raspberrypi2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@ -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>`.
|
||||
|
||||
|
||||
@ -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
137
docs/service.rst
Normal 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
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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)
|
||||
@ -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
139
mopidy/audio/tags.py
Normal 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]]
|
||||
@ -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)))
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
42
mopidy/internal/gi.py
Normal 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',
|
||||
]
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -426,3 +426,27 @@ def stop(context):
|
||||
Stops playing.
|
||||
"""
|
||||
context.core.playback.stop()
|
||||
|
||||
|
||||
@protocol.commands.add('volume', change=protocol.INT)
|
||||
def volume(context, change):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
|
||||
``volume {CHANGE}``
|
||||
|
||||
Changes volume by amount ``CHANGE``.
|
||||
|
||||
Note: ``volume`` is deprecated, use ``setvol`` instead.
|
||||
"""
|
||||
if change < -100 or change > 100:
|
||||
raise exceptions.MpdArgError('Invalid volume value')
|
||||
|
||||
old_volume = context.core.mixer.get_volume().get()
|
||||
if old_volume is None:
|
||||
raise exceptions.MpdSystemError('problems setting volume')
|
||||
|
||||
new_volume = min(max(0, old_volume + change), 100)
|
||||
success = context.core.mixer.set_volume(new_volume).get()
|
||||
if not success:
|
||||
raise exceptions.MpdSystemError('problems setting volume')
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
|
||||
from mopidy.models import TlTrack
|
||||
from mopidy.mpd.protocol import tagtype_list
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# TODO: special handling of local:// uri scheme
|
||||
normalize_path_re = re.compile(r'[^/]+')
|
||||
|
||||
@ -34,8 +38,12 @@ def track_to_mpd_format(track, position=None, stream_title=None):
|
||||
else:
|
||||
(tlid, track) = (None, track)
|
||||
|
||||
if not track.uri:
|
||||
logger.warning('Ignoring track without uri')
|
||||
return []
|
||||
|
||||
result = [
|
||||
('file', track.uri or ''),
|
||||
('file', track.uri),
|
||||
('Time', track.length and (track.length // 1000) or 0),
|
||||
('Artist', concat_multi_values(track.artists, 'name')),
|
||||
('Album', track.album and track.album.name or ''),
|
||||
@ -164,7 +172,9 @@ def tracks_to_mpd_format(tracks, start=0, end=None):
|
||||
assert len(tracks) == len(positions)
|
||||
result = []
|
||||
for track, position in zip(tracks, positions):
|
||||
result.append(track_to_mpd_format(track, position))
|
||||
formatted_track = track_to_mpd_format(track, position)
|
||||
if formatted_track:
|
||||
result.append(formatted_track)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
333
tests/audio/test_tags.py
Normal 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]))
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user