Merge branch 'develop' of https://github.com/mopidy/mopidy into feature/extra_tags
This commit is contained in:
commit
9ba5f6862f
1
AUTHORS
1
AUTHORS
@ -28,3 +28,4 @@
|
||||
- Pavol Babincak <scroolik@gmail.com>
|
||||
- Javier Domingo <javierdo1@gmail.com>
|
||||
- Lasse Bigum <lasse@bigum.org>
|
||||
- David Eisner <david.eisner@oriel.oxon.org>
|
||||
|
||||
@ -13,15 +13,48 @@ v0.17.0 (UNRELEASED)
|
||||
- The search field ``track`` has been renamed to ``track_name`` to avoid
|
||||
confusion with ``track_no``. (Fixes: :issue:`535`)
|
||||
|
||||
- The signature of the tracklist's
|
||||
:meth:`~mopidy.core.TracklistController.filter` and
|
||||
:meth:`~mopidy.core.TracklistController.remove` methods have changed.
|
||||
Previously, they expected e.g. ``tracklist.filter(tlid=17)``. Now, the value
|
||||
must always be a list, e.g. ``tracklist.filter(tlid=[17])``. This change
|
||||
allows you to get or remove multiple tracks with a single call, e.g.
|
||||
``tracklist.remove(tlid=[1, 2, 7])``. This is especially useful for web
|
||||
clients, as requests can be batched. This also brings the interface closer to
|
||||
the library's :meth:`~mopidy.core.LibraryController.find_exact` and
|
||||
:meth:`~mopidy.core.LibraryController.search` methods.
|
||||
|
||||
**Local backend**
|
||||
|
||||
- Library scanning has been switched back to custom code due to various issues
|
||||
with GStreamer's built in scanner in 0.10. This also fixes the scanner
|
||||
slowdown. (Fixes: :issue:`565`)
|
||||
|
||||
- When scanning, we no longer default the album artist to be the same as the
|
||||
track artist. Album artist is now only populated if the scanned file got an
|
||||
explicit album artist set.
|
||||
- Library scanning has been switched back to custom code due to various issues
|
||||
with GStreamer's built in scanner in 0.10. This also fixes the scanner slowdown.
|
||||
(Fixes: :issue:`565`)
|
||||
- Fix scanner so that mtime is respected when deciding which files can be skipped.
|
||||
|
||||
- The scanner will now extract multiple artists from files with multiple artist
|
||||
tags.
|
||||
|
||||
- Fix scanner so that time of last modification is respected when deciding
|
||||
which files can be skipped.
|
||||
|
||||
**MPD frontend**
|
||||
|
||||
- The MPD service is now published as a Zeroconf service if avahi-daemon is
|
||||
running on the system. Some MPD clients will use this to present Mopidy as an
|
||||
available server on the local network without needing any configuration. See
|
||||
the :confval:`mpd/zeroconf` config value to change the service name or
|
||||
disable the service. (Fixes: :issue:`39`)
|
||||
|
||||
**HTTP frontend**
|
||||
|
||||
- The HTTP service is now published as a Zeroconf service if avahi-daemon is
|
||||
running on the system. Some browsers will present HTTP Zeroconf services on
|
||||
the local network as "local sites" bookmarks. See the
|
||||
:confval:`http/zeroconf` config value to change the service name or disable
|
||||
the service. (Fixes: :issue:`39`)
|
||||
|
||||
**MPD frontend**
|
||||
|
||||
|
||||
@ -59,6 +59,13 @@ Configuration values
|
||||
Change this to have Mopidy serve e.g. files for your JavaScript client.
|
||||
"/mopidy" will continue to work as usual even if you change this setting.
|
||||
|
||||
.. confval:: http/zeroconf
|
||||
|
||||
Name of the HTTP service when published through Zeroconf. The variables
|
||||
``$hostname`` and ``$port`` can be used in the name.
|
||||
|
||||
Set to an empty string to disable Zeroconf for HTTP.
|
||||
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
@ -96,6 +96,13 @@ Configuration values
|
||||
Number of seconds an MPD client can stay inactive before the connection is
|
||||
closed by the server.
|
||||
|
||||
.. confval:: mpd/zeroconf
|
||||
|
||||
Name of the MPD service when published through Zeroconf. The variables
|
||||
``$hostname`` and ``$port`` can be used in the name.
|
||||
|
||||
Set to an empty string to disable Zeroconf for MPD.
|
||||
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
@ -150,7 +150,13 @@ def audio_data_to_track(data):
|
||||
track_kwargs['last_modified'] = int(data['mtime'])
|
||||
track_kwargs['length'] = data[gst.TAG_DURATION] // gst.MSECOND
|
||||
track_kwargs['album'] = Album(**album_kwargs)
|
||||
track_kwargs['artists'] = [Artist(**artist_kwargs)]
|
||||
|
||||
if ('name' in artist_kwargs
|
||||
and not isinstance(artist_kwargs['name'], basestring)):
|
||||
track_kwargs['artists'] = [Artist(name=artist)
|
||||
for artist in artist_kwargs['name']]
|
||||
else:
|
||||
track_kwargs['artists'] = [Artist(**artist_kwargs)]
|
||||
|
||||
if ('name' in composer_kwargs
|
||||
and not isinstance(composer_kwargs['name'], basestring)):
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import collections
|
||||
import logging
|
||||
import random
|
||||
|
||||
@ -292,36 +293,51 @@ class TracklistController(object):
|
||||
"""
|
||||
Filter the tracklist by the given criterias.
|
||||
|
||||
A criteria consists of a model field to check and a list of values to
|
||||
compare it against. If the model field matches one of the values, it
|
||||
may be returned.
|
||||
|
||||
Only tracks that matches all the given criterias are returned.
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns track with TLID 7 (tracklist ID)
|
||||
filter({'tlid': 7})
|
||||
filter(tlid=7)
|
||||
# Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID)
|
||||
filter({'tlid': [1, 2, 3, 4]})
|
||||
filter(tlid=[1, 2, 3, 4])
|
||||
|
||||
# Returns track with ID 1
|
||||
filter({'id': 1})
|
||||
filter(id=1)
|
||||
# Returns track with IDs 1, 5, or 7
|
||||
filter({'id': [1, 5, 7]})
|
||||
filter(id=[1, 5, 7])
|
||||
|
||||
# Returns track with URI 'xyz'
|
||||
filter({'uri': 'xyz'})
|
||||
filter(uri='xyz')
|
||||
# Returns track with URIs 'xyz' or 'abc'
|
||||
filter({'uri': ['xyz', 'abc']})
|
||||
filter(uri=['xyz', 'abc'])
|
||||
|
||||
# Returns track with ID 1 and URI 'xyz'
|
||||
filter({'id': 1, 'uri': 'xyz'})
|
||||
filter(id=1, uri='xyz')
|
||||
# Returns tracks with ID 1 and URI 'xyz'
|
||||
filter({'id': [1], 'uri': ['xyz']})
|
||||
filter(id=[1], uri=['xyz'])
|
||||
|
||||
# Returns track with a matching ID (1, 3 or 6) and a matching URI
|
||||
# ('xyz' or 'abc')
|
||||
filter({'id': [1, 3, 6], 'uri': ['xyz', 'abc']})
|
||||
filter(id=[1, 3, 6], uri=['xyz', 'abc'])
|
||||
|
||||
:param criteria: on or more criteria to match by
|
||||
:type criteria: dict
|
||||
:type criteria: dict, of (string, list) pairs
|
||||
:rtype: list of :class:`mopidy.models.TlTrack`
|
||||
"""
|
||||
criteria = criteria or kwargs
|
||||
matches = self._tl_tracks
|
||||
for (key, value) in criteria.iteritems():
|
||||
for (key, values) in criteria.iteritems():
|
||||
if (not isinstance(values, collections.Iterable)
|
||||
or isinstance(values, basestring)):
|
||||
# Fail hard if anyone is using the <0.17 calling style
|
||||
raise ValueError('Filter values must be iterable: %r' % values)
|
||||
if key == 'tlid':
|
||||
matches = filter(lambda ct: ct.tlid == value, matches)
|
||||
matches = filter(lambda ct: ct.tlid in values, matches)
|
||||
else:
|
||||
matches = filter(
|
||||
lambda ct: getattr(ct.track, key) == value, matches)
|
||||
lambda ct: getattr(ct.track, key) in values, matches)
|
||||
return matches
|
||||
|
||||
def move(self, start, end, to_position):
|
||||
@ -435,7 +451,7 @@ class TracklistController(object):
|
||||
"""Private method used by :class:`mopidy.core.PlaybackController`."""
|
||||
if not self.consume:
|
||||
return False
|
||||
self.remove(tlid=tl_track.tlid)
|
||||
self.remove(tlid=[tl_track.tlid])
|
||||
return True
|
||||
|
||||
def _trigger_tracklist_changed(self):
|
||||
|
||||
@ -21,6 +21,7 @@ class Extension(ext.Extension):
|
||||
schema['hostname'] = config.Hostname()
|
||||
schema['port'] = config.Port()
|
||||
schema['static_dir'] = config.Path(optional=True)
|
||||
schema['zeroconf'] = config.String(optional=True)
|
||||
return schema
|
||||
|
||||
def validate_environment(self):
|
||||
|
||||
@ -11,6 +11,7 @@ from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool
|
||||
|
||||
from mopidy import models
|
||||
from mopidy.core import CoreListener
|
||||
from mopidy.utils import zeroconf
|
||||
from . import ws
|
||||
|
||||
|
||||
@ -22,6 +23,12 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
|
||||
super(HttpFrontend, self).__init__()
|
||||
self.config = config
|
||||
self.core = core
|
||||
|
||||
self.hostname = config['http']['hostname']
|
||||
self.port = config['http']['port']
|
||||
self.zeroconf_name = config['http']['zeroconf']
|
||||
self.zeroconf_service = None
|
||||
|
||||
self._setup_server()
|
||||
self._setup_websocket_plugin()
|
||||
app = self._create_app()
|
||||
@ -30,8 +37,8 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
|
||||
def _setup_server(self):
|
||||
cherrypy.config.update({
|
||||
'engine.autoreload_on': False,
|
||||
'server.socket_host': self.config['http']['hostname'],
|
||||
'server.socket_port': self.config['http']['port'],
|
||||
'server.socket_host': self.hostname,
|
||||
'server.socket_port': self.port,
|
||||
})
|
||||
|
||||
def _setup_websocket_plugin(self):
|
||||
@ -88,7 +95,21 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
|
||||
cherrypy.engine.start()
|
||||
logger.info('HTTP server running at %s', cherrypy.server.base())
|
||||
|
||||
if self.zeroconf_name:
|
||||
self.zeroconf_service = zeroconf.Zeroconf(
|
||||
stype='_http._tcp', name=self.zeroconf_name,
|
||||
host=self.hostname, port=self.port)
|
||||
|
||||
if self.zeroconf_service.publish():
|
||||
logger.info('Registered HTTP with Zeroconf as "%s"',
|
||||
self.zeroconf_service.name)
|
||||
else:
|
||||
logger.warning('Registering HTTP with Zeroconf failed.')
|
||||
|
||||
def on_stop(self):
|
||||
if self.zeroconf_service:
|
||||
self.zeroconf_service.unpublish()
|
||||
|
||||
logger.debug('Stopping HTTP server')
|
||||
cherrypy.engine.exit()
|
||||
logger.info('Stopped HTTP server')
|
||||
|
||||
@ -3,6 +3,7 @@ enabled = true
|
||||
hostname = 127.0.0.1
|
||||
port = 6680
|
||||
static_dir =
|
||||
zeroconf = Mopidy HTTP server on $hostname
|
||||
|
||||
[loglevels]
|
||||
cherrypy = warning
|
||||
|
||||
@ -23,6 +23,7 @@ class Extension(ext.Extension):
|
||||
schema['password'] = config.Secret(optional=True)
|
||||
schema['max_connections'] = config.Integer(minimum=1)
|
||||
schema['connection_timeout'] = config.Integer(minimum=1)
|
||||
schema['zeroconf'] = config.String(optional=True)
|
||||
return schema
|
||||
|
||||
def validate_environment(self):
|
||||
|
||||
@ -7,7 +7,7 @@ import pykka
|
||||
|
||||
from mopidy.core import CoreListener
|
||||
from mopidy.frontends.mpd import session
|
||||
from mopidy.utils import encoding, network, process
|
||||
from mopidy.utils import encoding, network, process, zeroconf
|
||||
|
||||
logger = logging.getLogger('mopidy.frontends.mpd')
|
||||
|
||||
@ -15,12 +15,16 @@ logger = logging.getLogger('mopidy.frontends.mpd')
|
||||
class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||
def __init__(self, config, core):
|
||||
super(MpdFrontend, self).__init__()
|
||||
|
||||
hostname = network.format_hostname(config['mpd']['hostname'])
|
||||
port = config['mpd']['port']
|
||||
self.hostname = hostname
|
||||
self.port = config['mpd']['port']
|
||||
self.zeroconf_name = config['mpd']['zeroconf']
|
||||
self.zeroconf_service = None
|
||||
|
||||
try:
|
||||
network.Server(
|
||||
hostname, port,
|
||||
self.hostname, self.port,
|
||||
protocol=session.MpdSession,
|
||||
protocol_kwargs={
|
||||
'config': config,
|
||||
@ -34,9 +38,24 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
|
||||
encoding.locale_decode(error))
|
||||
sys.exit(1)
|
||||
|
||||
logger.info('MPD server running at [%s]:%s', hostname, port)
|
||||
logger.info('MPD server running at [%s]:%s', self.hostname, self.port)
|
||||
|
||||
def on_start(self):
|
||||
if self.zeroconf_name:
|
||||
self.zeroconf_service = zeroconf.Zeroconf(
|
||||
stype='_mpd._tcp', name=self.zeroconf_name,
|
||||
host=self.hostname, port=self.port)
|
||||
|
||||
if self.zeroconf_service.publish():
|
||||
logger.info('Registered MPD with Zeroconf as "%s"',
|
||||
self.zeroconf_service.name)
|
||||
else:
|
||||
logger.warning('Registering MPD with Zeroconf failed.')
|
||||
|
||||
def on_stop(self):
|
||||
if self.zeroconf_service:
|
||||
self.zeroconf_service.unpublish()
|
||||
|
||||
process.stop_actors_by_class(session.MpdSession)
|
||||
|
||||
def send_idle(self, subsystem):
|
||||
|
||||
@ -5,3 +5,4 @@ port = 6600
|
||||
password =
|
||||
max_connections = 20
|
||||
connection_timeout = 60
|
||||
zeroconf = Mopidy MPD server on $hostname
|
||||
|
||||
@ -76,7 +76,7 @@ def delete_range(context, start, end=None):
|
||||
if not tl_tracks:
|
||||
raise MpdArgError('Bad song index', command='delete')
|
||||
for (tlid, _) in tl_tracks:
|
||||
context.core.tracklist.remove(tlid=tlid)
|
||||
context.core.tracklist.remove(tlid=[tlid])
|
||||
|
||||
|
||||
@handle_request(r'^delete "(?P<songpos>\d+)"$')
|
||||
@ -86,7 +86,7 @@ def delete_songpos(context, songpos):
|
||||
songpos = int(songpos)
|
||||
(tlid, _) = context.core.tracklist.slice(
|
||||
songpos, songpos + 1).get()[0]
|
||||
context.core.tracklist.remove(tlid=tlid)
|
||||
context.core.tracklist.remove(tlid=[tlid])
|
||||
except IndexError:
|
||||
raise MpdArgError('Bad song index', command='delete')
|
||||
|
||||
@ -101,7 +101,7 @@ def deleteid(context, tlid):
|
||||
Deletes the song ``SONGID`` from the playlist
|
||||
"""
|
||||
tlid = int(tlid)
|
||||
tl_tracks = context.core.tracklist.remove(tlid=tlid).get()
|
||||
tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get()
|
||||
if not tl_tracks:
|
||||
raise MpdNoExistError('No such song', command='deleteid')
|
||||
|
||||
@ -157,7 +157,7 @@ def moveid(context, tlid, to):
|
||||
"""
|
||||
tlid = int(tlid)
|
||||
to = int(to)
|
||||
tl_tracks = context.core.tracklist.filter(tlid=tlid).get()
|
||||
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
|
||||
if not tl_tracks:
|
||||
raise MpdNoExistError('No such song', command='moveid')
|
||||
position = context.core.tracklist.index(tl_tracks[0]).get()
|
||||
@ -195,7 +195,7 @@ def playlistfind(context, tag, needle):
|
||||
- does not add quotes around the tag.
|
||||
"""
|
||||
if tag == 'filename':
|
||||
tl_tracks = context.core.tracklist.filter(uri=needle).get()
|
||||
tl_tracks = context.core.tracklist.filter(uri=[needle]).get()
|
||||
if not tl_tracks:
|
||||
return None
|
||||
position = context.core.tracklist.index(tl_tracks[0]).get()
|
||||
@ -215,7 +215,7 @@ def playlistid(context, tlid=None):
|
||||
"""
|
||||
if tlid is not None:
|
||||
tlid = int(tlid)
|
||||
tl_tracks = context.core.tracklist.filter(tlid=tlid).get()
|
||||
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
|
||||
if not tl_tracks:
|
||||
raise MpdNoExistError('No such song', command='playlistid')
|
||||
position = context.core.tracklist.index(tl_tracks[0]).get()
|
||||
@ -380,8 +380,8 @@ def swapid(context, tlid1, tlid2):
|
||||
"""
|
||||
tlid1 = int(tlid1)
|
||||
tlid2 = int(tlid2)
|
||||
tl_tracks1 = context.core.tracklist.filter(tlid=tlid1).get()
|
||||
tl_tracks2 = context.core.tracklist.filter(tlid=tlid2).get()
|
||||
tl_tracks1 = context.core.tracklist.filter(tlid=[tlid1]).get()
|
||||
tl_tracks2 = context.core.tracklist.filter(tlid=[tlid2]).get()
|
||||
if not tl_tracks1 or not tl_tracks2:
|
||||
raise MpdNoExistError('No such song', command='swapid')
|
||||
position1 = context.core.tracklist.index(tl_tracks1[0]).get()
|
||||
|
||||
@ -151,7 +151,7 @@ def playid(context, tlid):
|
||||
tlid = int(tlid)
|
||||
if tlid == -1:
|
||||
return _play_minus_one(context)
|
||||
tl_tracks = context.core.tracklist.filter(tlid=tlid).get()
|
||||
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
|
||||
if not tl_tracks:
|
||||
raise MpdNoExistError('No such song', command='playid')
|
||||
return context.core.playback.play(tl_tracks[0]).get()
|
||||
|
||||
@ -12,9 +12,8 @@ def setup_logging(config, verbosity_level, save_debug_log):
|
||||
setup_console_logging(config, verbosity_level)
|
||||
if save_debug_log:
|
||||
setup_debug_logging_to_file(config)
|
||||
if hasattr(logging, 'captureWarnings'):
|
||||
# New in Python 2.7
|
||||
logging.captureWarnings(True)
|
||||
|
||||
logging.captureWarnings(True)
|
||||
|
||||
if config['logging']['config_file']:
|
||||
logging.config.fileConfig(config['logging']['config_file'])
|
||||
|
||||
81
mopidy/utils/zeroconf.py
Normal file
81
mopidy/utils/zeroconf.py
Normal file
@ -0,0 +1,81 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import string
|
||||
|
||||
logger = logging.getLogger('mopidy.utils.zerconf')
|
||||
|
||||
try:
|
||||
import dbus
|
||||
except ImportError:
|
||||
dbus = None
|
||||
|
||||
_AVAHI_IF_UNSPEC = -1
|
||||
_AVAHI_PROTO_UNSPEC = -1
|
||||
_AVAHI_PUBLISHFLAGS_NONE = 0
|
||||
|
||||
|
||||
def _filter_loopback_and_meta_addresses(host):
|
||||
# TODO: see if we can find a cleaner way of handling this.
|
||||
if re.search(r'(?<![.\d])(127|0)[.]', host):
|
||||
return ''
|
||||
return host
|
||||
|
||||
|
||||
def _convert_text_to_dbus_bytes(text):
|
||||
return [dbus.Byte(ord(c)) for c in text]
|
||||
|
||||
|
||||
class Zeroconf(object):
|
||||
"""Publish a network service with Zeroconf using Avahi."""
|
||||
|
||||
def __init__(self, name, port, stype=None, domain=None,
|
||||
host=None, text=None):
|
||||
self.group = None
|
||||
self.stype = stype or '_http._tcp'
|
||||
self.domain = domain or ''
|
||||
self.port = port
|
||||
self.text = text or []
|
||||
self.host = _filter_loopback_and_meta_addresses(host or '')
|
||||
|
||||
template = string.Template(name)
|
||||
self.name = template.safe_substitute(
|
||||
hostname=self.host or socket.getfqdn(), port=self.port)
|
||||
|
||||
def publish(self):
|
||||
if not dbus:
|
||||
logger.debug('Zeroconf publish failed: dbus not installed.')
|
||||
return False
|
||||
|
||||
try:
|
||||
bus = dbus.SystemBus()
|
||||
except dbus.exceptions.DBusException as e:
|
||||
logger.debug('Zeroconf publish failed: %s', e)
|
||||
return False
|
||||
|
||||
if not bus.name_has_owner('org.freedesktop.Avahi'):
|
||||
logger.debug('Zeroconf publish failed: Avahi service not running.')
|
||||
return False
|
||||
|
||||
server = dbus.Interface(bus.get_object('org.freedesktop.Avahi', '/'),
|
||||
'org.freedesktop.Avahi.Server')
|
||||
|
||||
self.group = dbus.Interface(
|
||||
bus.get_object('org.freedesktop.Avahi', server.EntryGroupNew()),
|
||||
'org.freedesktop.Avahi.EntryGroup')
|
||||
|
||||
text = [_convert_text_to_dbus_bytes(t) for t in self.text]
|
||||
self.group.AddService(_AVAHI_IF_UNSPEC, _AVAHI_PROTO_UNSPEC,
|
||||
dbus.UInt32(_AVAHI_PUBLISHFLAGS_NONE),
|
||||
self.name, self.stype, self.domain, self.host,
|
||||
dbus.UInt16(self.port), text)
|
||||
|
||||
self.group.Commit()
|
||||
return True
|
||||
|
||||
def unpublish(self):
|
||||
if self.group:
|
||||
self.group.Reset()
|
||||
self.group = None
|
||||
@ -50,11 +50,18 @@ class TranslatorTest(unittest.TestCase):
|
||||
'musicbrainz_id': 'mbalbumid',
|
||||
}
|
||||
|
||||
self.artist = {
|
||||
self.artist_single = {
|
||||
'name': 'name',
|
||||
'musicbrainz_id': 'mbartistid',
|
||||
}
|
||||
|
||||
self.artist_multiple = {
|
||||
'name': ['name1', 'name2'],
|
||||
'musicbrainz_id': 'mbartistid',
|
||||
}
|
||||
|
||||
self.artist = self.artist_single
|
||||
|
||||
self.composer_single = {
|
||||
'name': 'composer',
|
||||
}
|
||||
@ -97,7 +104,13 @@ class TranslatorTest(unittest.TestCase):
|
||||
if self.albumartist:
|
||||
self.album['artists'] = [Artist(**self.albumartist)]
|
||||
self.track['album'] = Album(**self.album)
|
||||
self.track['artists'] = [Artist(**self.artist)]
|
||||
|
||||
if ('name' in self.artist
|
||||
and not isinstance(self.artist['name'], basestring)):
|
||||
self.track['artists'] = [Artist(name=artist)
|
||||
for artist in self.artist['name']]
|
||||
else:
|
||||
self.track['artists'] = [Artist(**self.artist)]
|
||||
|
||||
if ('name' in self.composer
|
||||
and not isinstance(self.composer['name'], basestring)):
|
||||
@ -185,6 +198,12 @@ class TranslatorTest(unittest.TestCase):
|
||||
del self.artist['musicbrainz_id']
|
||||
self.check()
|
||||
|
||||
def test_multiple_track_artists(self):
|
||||
self.data['artist'] = ['name1', 'name2']
|
||||
self.data['musicbrainz-artistid'] = 'mbartistid'
|
||||
self.artist = self.artist_multiple
|
||||
self.check()
|
||||
|
||||
def test_missing_album_artist(self):
|
||||
del self.data['album-artist']
|
||||
del self.albumartist['name']
|
||||
|
||||
@ -71,34 +71,34 @@ class LocalTracklistProviderTest(unittest.TestCase):
|
||||
def test_filter_by_tlid(self):
|
||||
tl_track = self.controller.tl_tracks[1]
|
||||
self.assertEqual(
|
||||
[tl_track], self.controller.filter(tlid=tl_track.tlid))
|
||||
[tl_track], self.controller.filter(tlid=[tl_track.tlid]))
|
||||
|
||||
@populate_tracklist
|
||||
def test_filter_by_uri(self):
|
||||
tl_track = self.controller.tl_tracks[1]
|
||||
self.assertEqual(
|
||||
[tl_track], self.controller.filter(uri=tl_track.track.uri))
|
||||
[tl_track], self.controller.filter(uri=[tl_track.track.uri]))
|
||||
|
||||
@populate_tracklist
|
||||
def test_filter_by_uri_returns_nothing_for_invalid_uri(self):
|
||||
self.assertEqual([], self.controller.filter(uri='foobar'))
|
||||
self.assertEqual([], self.controller.filter(uri=['foobar']))
|
||||
|
||||
def test_filter_by_uri_returns_single_match(self):
|
||||
track = Track(uri='a')
|
||||
self.controller.add([Track(uri='z'), track, Track(uri='y')])
|
||||
self.assertEqual(track, self.controller.filter(uri='a')[0].track)
|
||||
self.assertEqual(track, self.controller.filter(uri=['a'])[0].track)
|
||||
|
||||
def test_filter_by_uri_returns_multiple_matches(self):
|
||||
track = Track(uri='a')
|
||||
self.controller.add([Track(uri='z'), track, track])
|
||||
tl_tracks = self.controller.filter(uri='a')
|
||||
tl_tracks = self.controller.filter(uri=['a'])
|
||||
self.assertEqual(track, tl_tracks[0].track)
|
||||
self.assertEqual(track, tl_tracks[1].track)
|
||||
|
||||
def test_filter_by_uri_returns_nothing_if_no_match(self):
|
||||
self.controller.playlist = Playlist(
|
||||
tracks=[Track(uri='z'), Track(uri='y')])
|
||||
self.assertEqual([], self.controller.filter(uri='a'))
|
||||
tracks=[Track(uri=['z']), Track(uri=['y'])])
|
||||
self.assertEqual([], self.controller.filter(uri=['a']))
|
||||
|
||||
def test_filter_by_multiple_criteria_returns_elements_matching_all(self):
|
||||
track1 = Track(uri='a', name='x')
|
||||
@ -106,18 +106,18 @@ class LocalTracklistProviderTest(unittest.TestCase):
|
||||
track3 = Track(uri='b', name='y')
|
||||
self.controller.add([track1, track2, track3])
|
||||
self.assertEqual(
|
||||
track1, self.controller.filter(uri='a', name='x')[0].track)
|
||||
track1, self.controller.filter(uri=['a'], name=['x'])[0].track)
|
||||
self.assertEqual(
|
||||
track2, self.controller.filter(uri='b', name='x')[0].track)
|
||||
track2, self.controller.filter(uri=['b'], name=['x'])[0].track)
|
||||
self.assertEqual(
|
||||
track3, self.controller.filter(uri='b', name='y')[0].track)
|
||||
track3, self.controller.filter(uri=['b'], name=['y'])[0].track)
|
||||
|
||||
def test_filter_by_criteria_that_is_not_present_in_all_elements(self):
|
||||
track1 = Track()
|
||||
track2 = Track(uri='b')
|
||||
track3 = Track()
|
||||
self.controller.add([track1, track2, track3])
|
||||
self.assertEqual(track2, self.controller.filter(uri='b')[0].track)
|
||||
self.assertEqual(track2, self.controller.filter(uri=['b'])[0].track)
|
||||
|
||||
@populate_tracklist
|
||||
def test_clear(self):
|
||||
@ -227,17 +227,29 @@ class LocalTracklistProviderTest(unittest.TestCase):
|
||||
track1 = self.controller.tracks[1]
|
||||
track2 = self.controller.tracks[2]
|
||||
version = self.controller.version
|
||||
self.controller.remove(uri=track1.uri)
|
||||
self.controller.remove(uri=[track1.uri])
|
||||
self.assertLess(version, self.controller.version)
|
||||
self.assertNotIn(track1, self.controller.tracks)
|
||||
self.assertEqual(track2, self.controller.tracks[1])
|
||||
|
||||
@populate_tracklist
|
||||
def test_removing_track_that_does_not_exist_does_nothing(self):
|
||||
self.controller.remove(uri='/nonexistant')
|
||||
self.controller.remove(uri=['/nonexistant'])
|
||||
|
||||
def test_removing_from_empty_playlist_does_nothing(self):
|
||||
self.controller.remove(uri='/nonexistant')
|
||||
self.controller.remove(uri=['/nonexistant'])
|
||||
|
||||
@populate_tracklist
|
||||
def test_remove_lists(self):
|
||||
track0 = self.controller.tracks[0]
|
||||
track1 = self.controller.tracks[1]
|
||||
track2 = self.controller.tracks[2]
|
||||
version = self.controller.version
|
||||
self.controller.remove(uri=[track0.uri, track2.uri])
|
||||
self.assertLess(version, self.controller.version)
|
||||
self.assertNotIn(track0, self.controller.tracks)
|
||||
self.assertNotIn(track2, self.controller.tracks)
|
||||
self.assertEqual(track1, self.controller.tracks[0])
|
||||
|
||||
@populate_tracklist
|
||||
def test_shuffle(self):
|
||||
|
||||
@ -105,7 +105,7 @@ class BackendEventsTest(unittest.TestCase):
|
||||
self.core.tracklist.add([Track(uri='dummy:a')]).get()
|
||||
send.reset_mock()
|
||||
|
||||
self.core.tracklist.remove(uri='dummy:a').get()
|
||||
self.core.tracklist.remove(uri=['dummy:a']).get()
|
||||
|
||||
self.assertEqual(send.call_args[0][0], 'tracklist_changed')
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ class TracklistTest(unittest.TestCase):
|
||||
self.assertEqual(tl_tracks, self.core.tracklist.tl_tracks[-1:])
|
||||
|
||||
def test_remove_removes_tl_tracks_matching_query(self):
|
||||
tl_tracks = self.core.tracklist.remove(name='foo')
|
||||
tl_tracks = self.core.tracklist.remove(name=['foo'])
|
||||
|
||||
self.assertEqual(2, len(tl_tracks))
|
||||
self.assertListEqual(self.tl_tracks[:2], tl_tracks)
|
||||
@ -46,7 +46,7 @@ class TracklistTest(unittest.TestCase):
|
||||
self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks)
|
||||
|
||||
def test_remove_works_with_dict_instead_of_kwargs(self):
|
||||
tl_tracks = self.core.tracklist.remove({'name': 'foo'})
|
||||
tl_tracks = self.core.tracklist.remove({'name': ['foo']})
|
||||
|
||||
self.assertEqual(2, len(tl_tracks))
|
||||
self.assertListEqual(self.tl_tracks[:2], tl_tracks)
|
||||
@ -55,15 +55,21 @@ class TracklistTest(unittest.TestCase):
|
||||
self.assertListEqual(self.tl_tracks[2:], self.core.tracklist.tl_tracks)
|
||||
|
||||
def test_filter_returns_tl_tracks_matching_query(self):
|
||||
tl_tracks = self.core.tracklist.filter(name='foo')
|
||||
tl_tracks = self.core.tracklist.filter(name=['foo'])
|
||||
|
||||
self.assertEqual(2, len(tl_tracks))
|
||||
self.assertListEqual(self.tl_tracks[:2], tl_tracks)
|
||||
|
||||
def test_filter_works_with_dict_instead_of_kwargs(self):
|
||||
tl_tracks = self.core.tracklist.filter({'name': 'foo'})
|
||||
tl_tracks = self.core.tracklist.filter({'name': ['foo']})
|
||||
|
||||
self.assertEqual(2, len(tl_tracks))
|
||||
self.assertListEqual(self.tl_tracks[:2], tl_tracks)
|
||||
|
||||
def test_filter_fails_if_values_isnt_iterable(self):
|
||||
self.assertRaises(ValueError, self.core.tracklist.filter, tlid=3)
|
||||
|
||||
def test_filter_fails_if_values_is_a_string(self):
|
||||
self.assertRaises(ValueError, self.core.tracklist.filter, uri='a')
|
||||
|
||||
# TODO Extract tracklist tests from the base backend tests
|
||||
|
||||
@ -28,6 +28,7 @@ class HttpEventsTest(unittest.TestCase):
|
||||
'hostname': '127.0.0.1',
|
||||
'port': 6680,
|
||||
'static_dir': None,
|
||||
'zeroconf': '',
|
||||
}
|
||||
}
|
||||
self.http = actor.HttpFrontend(config=config, core=mock.Mock())
|
||||
|
||||
Loading…
Reference in New Issue
Block a user